diff --git a/backend/cache.py b/backend/cache.py deleted file mode 100644 index 29c471d..0000000 --- a/backend/cache.py +++ /dev/null @@ -1,103 +0,0 @@ -"""BAN YARO — In-Memory TTL-Cache für statische DB-Daten. - -Hintergrund: - Routes wie /api/training/exercises, /api/help, /api/wiki/stats laden bei - jedem Request statische Daten aus der DB. Das ist verschwendete Energie. - Diese Daten ändern sich nur durch Admin-Aktionen. - -Verwendung: - from cache import ttl_cache - - @ttl_cache(ttl=3600) - def my_func(arg1, arg2): - ... - -API: - @ttl_cache(ttl=3600) Decorator – cached pro Argumenten-Signatur - my_func.cache_clear() Komplett leeren (z.B. nach Admin-Update) - -Hinweis: - Diese Implementierung ist absichtlich klein und ohne externe Dependency - (kein cachetools nötig). Thread-safe via Lock. Reicht für Read-Only- - Listen, die sich selten ändern. Niemals für user-spezifische Daten - verwenden! -""" -from __future__ import annotations - -import functools -import threading -import time -from typing import Any, Callable - - -def ttl_cache(ttl: int = 3600, maxsize: int = 128) -> Callable: - """Decorator: cached Rückgabewert pro Argumenten-Signatur für `ttl` Sek. - - - ttl: Time-to-live in Sekunden (Default: 1 Stunde) - - maxsize: max. Anzahl Einträge im Cache (FIFO-Eviction bei Überlauf) - - Die dekorierte Funktion bekommt zusätzlich: - .cache_clear() – leert den gesamten Cache - .cache_info() – {hits, misses, size, ttl, maxsize} - """ - def decorator(func: Callable) -> Callable: - store: dict[tuple, tuple[float, Any]] = {} - lock = threading.Lock() - stats = {"hits": 0, "misses": 0} - - def _make_key(args: tuple, kwargs: dict) -> tuple: - # kwargs als sortiertes Tuple in den Key packen - if kwargs: - return args + tuple(sorted(kwargs.items())) - return args - - @functools.wraps(func) - def wrapper(*args, **kwargs): - key = _make_key(args, kwargs) - now = time.monotonic() - with lock: - cached = store.get(key) - if cached is not None: - expires_at, value = cached - if expires_at > now: - stats["hits"] += 1 - return value - # abgelaufen → raus - del store[key] - stats["misses"] += 1 - - # Außerhalb des Locks ausführen (kann DB-Calls machen) - value = func(*args, **kwargs) - - with lock: - # FIFO-Eviction, wenn maxsize überschritten - if len(store) >= maxsize: - try: - oldest_key = next(iter(store)) - del store[oldest_key] - except StopIteration: - pass - store[key] = (now + ttl, value) - return value - - def cache_clear() -> None: - with lock: - store.clear() - stats["hits"] = 0 - stats["misses"] = 0 - - def cache_info() -> dict: - with lock: - return { - "hits": stats["hits"], - "misses": stats["misses"], - "size": len(store), - "ttl": ttl, - "maxsize": maxsize, - } - - wrapper.cache_clear = cache_clear # type: ignore[attr-defined] - wrapper.cache_info = cache_info # type: ignore[attr-defined] - return wrapper - - return decorator diff --git a/backend/database.py b/backend/database.py index 00a5210..7368eb7 100644 --- a/backend/database.py +++ b/backend/database.py @@ -180,8 +180,6 @@ def init_db(): anz_bewertungen INTEGER DEFAULT 0, created_at TEXT NOT NULL DEFAULT (datetime('now')) ); - CREATE INDEX IF NOT EXISTS idx_routes_user ON routes(user_id, created_at DESC); - CREATE TABLE IF NOT EXISTS route_walks ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -274,8 +272,6 @@ def init_db(): created_at TEXT NOT NULL DEFAULT (datetime('now')), edited_at TEXT ); - CREATE INDEX IF NOT EXISTS idx_forum_posts_thread ON forum_posts(thread_id, created_at ASC); - CREATE INDEX IF NOT EXISTS idx_forum_posts_user ON forum_posts(user_id, created_at DESC); -- PUSH SUBSCRIPTIONS (alternativ zu users.push_sub für mehrere Geräte) CREATE TABLE IF NOT EXISTS push_subscriptions ( diff --git a/backend/main.py b/backend/main.py index 8378aee..d63ae64 100644 --- a/backend/main.py +++ b/backend/main.py @@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request): raise _HE(404, "Nicht gefunden.") return _media_response(filepath) -APP_VER = "1090" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1070" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/admin.py b/backend/routes/admin.py index bfd02f8..c0ec221 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -359,7 +359,7 @@ async def list_users( SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium, u.is_moderator, u.is_banned, u.ban_reason, u.is_founder, u.is_partner, u.founder_number, - u.created_at, u.last_login, u.last_seen, u.subscription_tier, + u.created_at, u.last_login, u.subscription_tier, (SELECT COUNT(*) FROM dogs d WHERE d.user_id=u.id) AS dog_count, (SELECT COUNT(*) FROM forum_threads t WHERE t.user_id=u.id AND t.is_deleted=0) AS thread_count, ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km, @@ -1139,16 +1139,7 @@ async def list_upgrade_requests(user=Depends(require_admin)): SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at, u.name, u.email, u.billing_address, u.is_founder, u.is_founder_pending, u.referred_by, - COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count, - (SELECT id FROM invoices i WHERE i.user_id=r.user_id - AND i.status IN ('draft','sent') - ORDER BY i.created_at DESC LIMIT 1) AS existing_invoice_id, - (SELECT invoice_number FROM invoices i WHERE i.user_id=r.user_id - AND i.status IN ('draft','sent') - ORDER BY i.created_at DESC LIMIT 1) AS existing_invoice_number, - (SELECT status FROM invoices i WHERE i.user_id=r.user_id - AND i.status IN ('draft','sent') - ORDER BY i.created_at DESC LIMIT 1) AS existing_invoice_status + COALESCE((SELECT COUNT(*) FROM users WHERE referred_by=u.id), 0) AS referral_count FROM upgrade_requests r JOIN users u ON u.id = r.user_id ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py index 0065d18..4ffdcd0 100644 --- a/backend/routes/alerts.py +++ b/backend/routes/alerts.py @@ -9,7 +9,6 @@ from auth import get_current_user_optional as get_optional_user router = APIRouter() _RADIUS_M = 20_000 # 20 km -_RADIUS_KM = _RADIUS_M / 1000.0 def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: @@ -21,36 +20,15 @@ def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: 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) - return (lat - lat_delta, lat + lat_delta, lon - lon_delta, lon + lon_delta) - - @router.get("") async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user)): now = datetime.utcnow().isoformat() - lat_min, lat_max, lon_min, lon_max = _bbox(lat, lon, _RADIUS_KM) with db() as conn: - # Bounding-Box-Vorfilter per SQL (billig) → reduziert die Kandidaten - # auf ~10 Einträge statt "alle". Die exakte Haversine-Prüfung passiert - # anschließend in Python. poisons = conn.execute( - """SELECT lat, lon FROM poison - WHERE geloest=0 AND expires_at > ? - AND lat BETWEEN ? AND ? - AND lon BETWEEN ? AND ?""", - (now, lat_min, lat_max, lon_min, lon_max) + "SELECT lat, lon FROM poison WHERE geloest=0 AND expires_at > ?", (now,) ).fetchall() lost = conn.execute( - """SELECT lat, lon FROM lost_dogs - WHERE is_active=1 - AND lat BETWEEN ? AND ? - AND lon BETWEEN ? AND ?""", - (lat_min, lat_max, lon_min, lon_max) + "SELECT lat, lon FROM lost_dogs WHERE is_active=1" ).fetchall() # Letzten Standort des Users für geo-basierte Push-Filter speichern if user: diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 7ee2d03..0ccc0cd 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -479,10 +479,3 @@ async def select_primary_dog(body: dict, user=Depends(get_current_user)): "UPDATE users SET needs_dog_selection=0 WHERE id=?", (user["id"],) ) return {"ok": True} - - -@router.post("/heartbeat") -async def heartbeat(user=Depends(get_current_user)): - with db() as conn: - conn.execute("UPDATE users SET last_seen=datetime('now') WHERE id=?", (user["id"],)) - return {"ok": True} diff --git a/backend/routes/breeder_photos.py b/backend/routes/breeder_photos.py index 18eb085..554cb91 100644 --- a/backend/routes/breeder_photos.py +++ b/backend/routes/breeder_photos.py @@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form from fastapi.responses import FileResponse from pydantic import BaseModel from typing import Optional -import os, logging, asyncio +import os, logging from database import db from auth import get_current_user, get_current_user_optional from media_utils import validate_upload, generate_preview @@ -112,37 +112,27 @@ async def upload_photo( file_uuid = str(uuid.uuid4()) file_path = os.path.join(save_dir, f"{file_uuid}.webp") - # Blockierende Bildverarbeitung in Threadpool auslagern, - # damit der Event-Loop für andere Requests frei bleibt. - loop = asyncio.get_event_loop() - - def _write_bytes(p: str, data: bytes) -> None: - with open(p, "wb") as f: - f.write(data) - # Thumbnail erzeugen - thumb_bytes = await loop.run_in_executor( - None, lambda: generate_preview(raw_data, ext) - ) + thumb_bytes = generate_preview(raw_data, ext) thumb_path = None if thumb_bytes: thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp") - await loop.run_in_executor(None, lambda: _write_bytes(thumb_path, thumb_bytes)) + with open(thumb_path, "wb") as f: + f.write(thumb_bytes) - # Originalbild konvertieren und speichern (Pillow direkt — WebP-Qualität 85) - def _save_original(): - try: - import io - from PIL import Image, ImageOps - img = Image.open(io.BytesIO(raw_data)) - img = ImageOps.exif_transpose(img) - img = img.convert("RGB") - img.save(file_path, format="WEBP", quality=85) - except Exception: - # Fallback: Rohdaten speichern - _write_bytes(file_path, raw_data) - - await loop.run_in_executor(None, _save_original) + # Originalbild konvertieren und speichern + # generate_preview liefert WebP, für das Original nehmen wir Pillow direkt + try: + import io + from PIL import Image, ImageOps + img = Image.open(io.BytesIO(raw_data)) + img = ImageOps.exif_transpose(img) + img = img.convert("RGB") + img.save(file_path, format="WEBP", quality=85) + except Exception: + # Fallback: Rohdaten speichern + with open(file_path, "wb") as f: + f.write(raw_data) # Relative Pfade für DB (relativ zu MEDIA_DIR) rel_file = os.path.relpath(file_path, MEDIA_DIR) diff --git a/backend/routes/diary.py b/backend/routes/diary.py index baf2586..6f6cd12 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, math, logging from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional @@ -684,13 +684,7 @@ async def upload_media(dog_id: int, entry_id: int, validate_upload(raw_data, file.filename or "") except ValueError as e: raise HTTPException(415, str(e)) - - # Blockierende Bild-/Video-Konvertierung in Threadpool auslagern, - # damit der Event-Loop für andere Requests frei bleibt. - loop = asyncio.get_event_loop() - raw_data, ext = await loop.run_in_executor( - None, lambda: convert_media(raw_data, file.filename or "") - ) + raw_data, ext = convert_media(raw_data, file.filename or "") if not ext: ext = ".jpg" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" @@ -698,21 +692,17 @@ async def upload_media(dog_id: int, entry_id: int, media_type = _guess_media_type(ct, file.filename or "") os.makedirs(os.path.dirname(path), exist_ok=True) - def _write_bytes(p: str, data: bytes) -> None: - with open(p, "wb") as f: - f.write(data) - - await loop.run_in_executor(None, lambda: _write_bytes(path, raw_data)) + with open(path, "wb") as f: + f.write(raw_data) if media_type == "video": - await loop.run_in_executor(None, lambda: extract_video_thumb(path)) + extract_video_thumb(path) elif media_type == "image": - preview_bytes = await loop.run_in_executor( - None, lambda: generate_preview(raw_data, ext) - ) + preview_bytes = generate_preview(raw_data, ext) if preview_bytes: preview_path = os.path.splitext(path)[0] + "_preview.webp" - await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes)) + with open(preview_path, "wb") as f: + f.write(preview_bytes) media_url = f"/media/diary/{filename}" @@ -720,8 +710,8 @@ async def upload_media(dog_id: int, entry_id: int, exif_gps = None img_size = None if media_type == "image": - exif_gps = await loop.run_in_executor(None, lambda: extract_gps_from_exif(raw_data)) - img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data)) + exif_gps = extract_gps_from_exif(raw_data) + img_size = get_image_size(raw_data) with db() as conn: # sort_order = nächste freie Position diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 9cc2820..45228ed 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -9,20 +9,6 @@ from database import db from auth import get_current_user, has_pro_access from routes.push import send_push_to_user from media_utils import safe_media_path, preview_url_from -from cache import ttl_cache - - -# ------------------------------------------------------------------ -# Pflege-Tipps sind statische Stamm-Daten → 1h TTL-Cache -# (Filterung pro Hund passiert weiter unten in-memory, NICHT gecached) -# ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _load_all_pflege_tipps() -> list[dict]: - with db() as conn: - rows = conn.execute( - "SELECT * FROM pflege_tipps ORDER BY kategorie, titel" - ).fetchall() - return [dict(r) for r in rows] router = APIRouter() MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -1109,8 +1095,10 @@ async def get_pflege_tipps(dog_id: int, user=Depends(get_current_user)): elif any(w in beschr for w in ["schneid", "geschoren", "schere", "clipper"]): fell_pflege_art_filter = "schneiden" - # Statische Tipps aus Cache (1h TTL) – Filterung passiert in-memory - alle_tipps = _load_all_pflege_tipps() + with db() as conn: + alle_tipps = conn.execute( + "SELECT * FROM pflege_tipps ORDER BY kategorie, titel" + ).fetchall() # Relevante Tipps: kein Fell-Filter oder passend from datetime import date diff --git a/backend/routes/help.py b/backend/routes/help.py index 6551e4d..4c77018 100644 --- a/backend/routes/help.py +++ b/backend/routes/help.py @@ -5,28 +5,10 @@ from pydantic import BaseModel from typing import Optional from database import db from auth import get_current_user_optional, require_admin -from cache import ttl_cache router = APIRouter() -# ------------------------------------------------------------------ -# Öffentliche, aktive FAQ-Liste – statisch, 1h TTL-Cache. -# Admin-Pfad (?all=1) wird NICHT gecached. -# Wird bei jedem schreibenden Admin-Endpoint unten invalidiert. -# ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _load_active_help_articles() -> list[dict]: - with db() as conn: - rows = conn.execute( - "SELECT id, kategorie, frage, antwort, sort_order, aktiv " - "FROM help_articles " - "WHERE aktiv = 1 " - "ORDER BY kategorie, sort_order, id" - ).fetchall() - return [dict(r) for r in rows] - - # ------------------------------------------------------------------ # Schemas # ------------------------------------------------------------------ @@ -57,17 +39,22 @@ def get_help( is_admin = user and user.get("rolle") == "admin" show_all = all == 1 and is_admin - if show_all: - with db() as conn: + with db() as conn: + if show_all: rows = conn.execute( "SELECT id, kategorie, frage, antwort, sort_order, aktiv " "FROM help_articles " "ORDER BY kategorie, sort_order, id" ).fetchall() - return [dict(r) for r in rows] + else: + rows = conn.execute( + "SELECT id, kategorie, frage, antwort, sort_order, aktiv " + "FROM help_articles " + "WHERE aktiv = 1 " + "ORDER BY kategorie, sort_order, id" + ).fetchall() - # Öffentliche, aktive Artikel kommen aus dem Cache - return _load_active_help_articles() + return [dict(r) for r in rows] # ------------------------------------------------------------------ @@ -81,7 +68,6 @@ def create_article(body: ArticleCreate, admin=Depends(require_admin)): "VALUES (?, ?, ?, ?, ?)", (body.kategorie, body.frage, body.antwort, body.sort_order, body.aktiv), ) - _load_active_help_articles.cache_clear() return {"ok": True, "id": cur.lastrowid} @@ -99,7 +85,6 @@ def update_article(article_id: int, body: ArticleUpdate, admin=Depends(require_a f"UPDATE help_articles SET {set_clause} WHERE id=?", (*updates.values(), article_id), ) - _load_active_help_articles.cache_clear() return {"ok": True} @@ -110,5 +95,4 @@ def update_article(article_id: int, body: ArticleUpdate, admin=Depends(require_a def delete_article(article_id: int, admin=Depends(require_admin)): with db() as conn: conn.execute("DELETE FROM help_articles WHERE id=?", (article_id,)) - _load_active_help_articles.cache_clear() return {"ok": True} diff --git a/backend/routes/social.py b/backend/routes/social.py index 1cf204d..494d9a2 100644 --- a/backend/routes/social.py +++ b/backend/routes/social.py @@ -1278,26 +1278,21 @@ except Exception: # ------------------------------------------------------------------ @router.post("/training-tip") async def training_tip(user=Depends(require_social_media)): - # Übung wählen die noch nicht als Social-Post verwendet wurde. - # Per SQL: zuerst eine unbenutzte zufällig wählen, sonst Reset (irgendeine). + # Übung wählen die noch nicht als Social-Post verwendet wurde with db() as conn: - row = conn.execute( - """SELECT * FROM training_exercises - WHERE exercise_id NOT IN ( - SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL - ) - ORDER BY RANDOM() LIMIT 1""" - ).fetchone() - if not row: - # Alle durch — Reset: irgendeine zufällige nehmen - row = conn.execute( - "SELECT * FROM training_exercises ORDER BY RANDOM() LIMIT 1" - ).fetchone() + used = {r["exercise_id"] for r in conn.execute( + "SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL" + ).fetchall()} + all_ex = conn.execute( + "SELECT * FROM training_exercises ORDER BY RANDOM()" + ).fetchall() - if not row: + unused = [e for e in all_ex if e["exercise_id"] not in used] + pool = unused if unused else list(all_ex) # Reset wenn alle durch + if not pool: raise HTTPException(404, "Keine Übungen gefunden.") - ex = dict(row) + ex = dict(pool[0]) stil = random.choice(_TRAINING_STILE) schritte_list = json.loads(ex["schritte"] or "[]") schritte_text = "\n".join(f" {i+1}. {s}" for i, s in enumerate(schritte_list[:4])) @@ -1568,10 +1563,8 @@ async def pflege_tipp(breed_id: Optional[int] = None, user=Depends(require_socia (breed_id,), ).fetchone() - # LIMIT 100 deckelt das Result-Set ab (Tabelle hat aktuell ~43 Einträge); - # der Python-Filter unten braucht mehrere Kandidaten für Fell-Typ-Auswahl. tipps = conn.execute( - "SELECT * FROM pflege_tipps ORDER BY RANDOM() LIMIT 100" + "SELECT * FROM pflege_tipps ORDER BY RANDOM()" ).fetchall() # Noch nicht verwendete bevorzugen diff --git a/backend/routes/training.py b/backend/routes/training.py index 078ceef..aaaa855 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -7,16 +7,15 @@ import datetime import ki from database import db from auth import get_current_user, require_admin -from cache import ttl_cache router = APIRouter() # ------------------------------------------------------------------ # Alle Übungen aus DB (öffentlich, kein Auth) -# Statische Daten → 1h TTL-Cache. Wird in update_exercise() invalidiert. # ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _load_exercises_by_tab() -> dict: +@router.get("/exercises") +async def get_exercises(): + """Alle Übungen aus der DB, gruppiert nach Tab-ID.""" import json as _json CAT_TO_TAB = { 'Grundkommando': 'grundkommandos', @@ -34,7 +33,7 @@ def _load_exercises_by_tab() -> dict: dauer, beschreibung, schritte, tipp FROM training_exercises ORDER BY kategorie, name """).fetchall() - by_tab: dict = {} + by_tab = {} for r in rows: tab = CAT_TO_TAB.get(r['kategorie'], r['kategorie'].lower().replace(' ', '-')) by_tab.setdefault(tab, []).append({ @@ -51,12 +50,6 @@ def _load_exercises_by_tab() -> dict: }) return by_tab - -@router.get("/exercises") -async def get_exercises(): - """Alle Übungen aus der DB, gruppiert nach Tab-ID (1h-Cache).""" - return _load_exercises_by_tab() - # ------------------------------------------------------------------ # Admin: Übung bearbeiten (beschreibung / schritte / tipp) # ------------------------------------------------------------------ @@ -85,8 +78,6 @@ async def update_exercise(exercise_id: int, body: ExerciseUpdate, _=Depends(requ return {"ok": True, "updated": 0} vals.append(exercise_id) conn.execute(f"UPDATE training_exercises SET {', '.join(fields)} WHERE id=?", vals) - # Cache invalidieren, damit der Admin-Edit sofort sichtbar wird - _load_exercises_by_tab.cache_clear() return {"ok": True, "updated": len(fields)} # ------------------------------------------------------------------ diff --git a/backend/routes/wiki.py b/backend/routes/wiki.py index a05bb1b..56df55d 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -10,7 +10,6 @@ from pydantic import BaseModel from database import db from auth import get_current_user, get_current_user_optional from ratelimit import check as rl_check, block_ip -from cache import ttl_cache logger = logging.getLogger(__name__) MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") @@ -82,34 +81,16 @@ def _quiz_score(rasse: dict, params: dict) -> int: # ------------------------------------------------------------------ -# GET /api/wiki/stats — Seed-Status (1h TTL-Cache, statische Anzahl) +# GET /api/wiki/stats — Seed-Status # ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _wiki_stats() -> dict: +@router.get("/stats") +async def get_stats(): with db() as conn: row = conn.execute("SELECT COUNT(*) as total FROM wiki_rassen").fetchone() total = row["total"] if row else 0 return {"total_breeds": total, "seeded": total > 0} -@router.get("/stats") -async def get_stats(): - return _wiki_stats() - - -# ------------------------------------------------------------------ -# Gruppen-Liste für Filter-Dropdown – statisch, 1h TTL-Cache -# ------------------------------------------------------------------ -@ttl_cache(ttl=3600) -def _wiki_gruppen() -> list[str]: - with db() as conn: - rows = conn.execute( - "SELECT DISTINCT gruppe FROM wiki_rassen " - "WHERE gruppe IS NOT NULL ORDER BY gruppe" - ).fetchall() - return [r["gruppe"] for r in rows] - - # ------------------------------------------------------------------ # GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert) # ------------------------------------------------------------------ @@ -153,13 +134,15 @@ async def get_rassen( SELECT COUNT(*) as total FROM wiki_rassen {where} """, args).fetchone() - # Alle Gruppen für Filter-Dropdown (gecached, 1h TTL) - gruppen = _wiki_gruppen() + # Alle Gruppen für Filter-Dropdown + gruppen_rows = conn.execute( + "SELECT DISTINCT gruppe FROM wiki_rassen WHERE gruppe IS NOT NULL ORDER BY gruppe" + ).fetchall() return { "breeds": [dict(r) for r in rows], "total": count_row["total"] if count_row else 0, - "gruppen": gruppen, + "gruppen": [r["gruppe"] for r in gruppen_rows], } diff --git a/backend/scheduler.py b/backend/scheduler.py index a6aef1f..aee77b7 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -24,19 +24,12 @@ _job_log: dict = {} def start(): - # ------------------------------------------------------------------ - # Job-Staffelung in 5-Minuten-Intervallen — verhindert gleichzeitige - # Last-Spitzen (mehrere Jobs zur selben Sekunde 08:00 Uhr). - # coalesce=True: bei verpassten Läufen nur ein Lauf nachholen. - # misfire_grace_time: Mindestwert 300s, höher wo Job lange dauern kann. - # ------------------------------------------------------------------ _scheduler.add_job( _job_health_reminders, CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr id="health_reminders", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_poison_archive, @@ -44,7 +37,6 @@ def start(): id="poison_archive", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_weather_alert, @@ -52,7 +44,6 @@ def start(): id="weather_alert", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_milestone_check, @@ -60,7 +51,6 @@ def start(): id="milestone_check", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_import_events, @@ -68,7 +58,6 @@ def start(): id="import_events", replace_existing=True, misfire_grace_time=7200, - coalesce=True, ) # Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung @@ -79,32 +68,29 @@ def start(): id="import_events_startup", replace_existing=True, ) - # 1. des Monats 03:00 — Rassen aus TheDogAPI aktualisieren + # Alle 4 Wochen Di 03:00 — Rassen aus TheDogAPI aktualisieren _scheduler.add_job( _job_seed_breeds, CronTrigger(day=1, hour=3, minute=0), # 1. jedes Monats id="seed_breeds", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # 1. des Monats 04:00 — fehlende Rassen aus Wikidata ergänzen + # Alle 4 Wochen Di 04:00 — fehlende Rassen aus Wikidata ergänzen _scheduler.add_job( _job_seed_wikidata_breeds, CronTrigger(day=1, hour=4, minute=0), # 1. jedes Monats id="seed_wikidata", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Jeden Montag 09:05 — Wöchentlicher Fortschritts-Lober (staggered) + # Jeden Montag 09:00 — Wöchentlicher Fortschritts-Lober _scheduler.add_job( _job_weekly_praise, - CronTrigger(day_of_week='mon', hour=9, minute=5), + CronTrigger(day_of_week='mon', hour=9, minute=0), id="weekly_praise", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 06:00 Uhr Status-Report per Mail _scheduler.add_job( @@ -113,7 +99,6 @@ def start(): id="status_report", replace_existing=True, misfire_grace_time=1800, - coalesce=True, ) # Täglich 12:00 — Moderation-Overdue-Check _scheduler.add_job( @@ -122,25 +107,22 @@ def start(): id="moderation_overdue", replace_existing=True, misfire_grace_time=1800, - coalesce=True, ) - # 1. Feb / Mai / Aug / Nov 07:10 — Quartalsbericht (staggered weg von 07:00) + # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht _scheduler.add_job( _job_quarterly_report, - CronTrigger(month="2,5,8,11", day=1, hour=7, minute=10), + CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0), id="quarterly_report", replace_existing=True, misfire_grace_time=7200, - coalesce=True, ) - # Jeden Montag 07:05 — KI-Gesundheitsberichte (staggered weg von 07:00) + # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) _scheduler.add_job( _job_ki_health_report, - CronTrigger(day_of_week='mon', hour=7, minute=5), + CronTrigger(day_of_week='mon', hour=7, minute=0), id="ki_health_report", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 06:30 — Wiederkehrende Ausgaben anlegen _scheduler.add_job( @@ -149,7 +131,6 @@ def start(): id="recurring_expenses", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # 1. des Monats 00:05 — Hund des Monats Sieger festlegen _scheduler.add_job( @@ -158,7 +139,6 @@ def start(): id="hdm_winner", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 19:00 Uhr — Streak-Erinnerung _scheduler.add_job( @@ -167,25 +147,22 @@ def start(): id="streak_reminder", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Täglich 08:05 Uhr — Tierfutter-Rückrufe prüfen (RASFF) (staggered weg von 08:00) + # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF) _scheduler.add_job( _job_recall_check, - CronTrigger(hour=8, minute=5), + CronTrigger(hour=8, minute=0), id="recall_check", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Jeden Montag 08:10 Uhr — Neue Foto-Challenge anlegen (staggered weg von 08:00) + # Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen _scheduler.add_job( _job_new_foto_challenge, - CronTrigger(day_of_week='mon', hour=8, minute=10), + CronTrigger(day_of_week='mon', hour=8, minute=0), id="new_foto_challenge", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 07:00 Uhr — Goldene Gassi-Stunde _scheduler.add_job( @@ -194,7 +171,6 @@ def start(): id="golden_gassi_hour", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren) _scheduler.add_job( @@ -203,7 +179,6 @@ def start(): id="anniversary_reminders", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) # 1. des Monats 10:00 — Monatlicher Rückblick per Push _scheduler.add_job( @@ -212,16 +187,13 @@ def start(): id="monthly_recap", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) - # Täglich 03:15 — Abo-Ablauf prüfen (staggered weg von 03:00 poison_archive) _scheduler.add_job( _job_subscription_check, - CronTrigger(hour=3, minute=15), + CronTrigger(hour=3, minute=0), id="subscription_check", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.add_job( _job_invoice_reminder, @@ -229,10 +201,9 @@ def start(): id="invoice_reminder", replace_existing=True, misfire_grace_time=3600, - coalesce=True, ) _scheduler.start() - logger.info("Scheduler gestartet (gestaffelt) — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import 1.+2./4./7./10. 02:00, Rassen-Seed 1. 03:00, Wikidata-Seed 1. 04:00, Status-Report 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:10, KI-Gesundheitsbericht Mo 07:05, Streak-Reminder 19:00, Rückruf-Check 08:05, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. 10:00, Foto-Challenge Mo 08:10, Weekly-Praise Mo 09:05, Abo-Check 03:15, Invoice-Reminder 08:30. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00, Foto-Challenge Mo 08:00, Abo-Check 03:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 4ec2cef..41d4e27 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8865,43 +8865,3 @@ svg.empty-state-icon { overflow: hidden; position: relative; } - -/* ============================================================ - Offline-Bereitschafts-Anzeige IM Welten-FAB - Die 5 Pfoten-Pfade werden je nach Score grün gefärbt - (Default = weiß auf orange, filled = grün auf orange) - ============================================================ */ -#worlds-fab .offline-paw .paw-elem { - color: #fff; /* stroke via currentColor — fill bleibt 'none' aus HTML */ - transition: stroke 0.4s ease; -} -#worlds-fab .offline-paw .paw-elem.filled { - color: #5C3517; /* dunkles Ban Yaro Braun — klar auf orangem FAB */ -} - -.offline-status-row { - display: flex; - align-items: center; - gap: var(--space-3); - padding: var(--space-2) var(--space-3); - border-radius: var(--radius-md); - border: 1px solid var(--c-border-light); - font-size: var(--text-sm); - margin-bottom: var(--space-2); -} -.offline-status-row .osr-check { - width: 24px; - height: 24px; - border-radius: 50%; - display: flex; - align-items: center; - justify-content: center; - flex-shrink: 0; - font-size: 14px; - font-weight: 700; -} -.offline-status-row.ok .osr-check { background: var(--c-success); color: #fff; } -.offline-status-row.miss .osr-check { background: var(--c-surface-2); color: var(--c-text-muted); border: 1px dashed var(--c-border); } -.offline-status-row .osr-text { flex: 1; min-width: 0; } -.offline-status-row .osr-title { font-weight: 600; } -.offline-status-row .osr-detail { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; } diff --git a/backend/static/index.html b/backend/static/index.html index 37c7fee..e8ea43a 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -602,16 +602,7 @@
@@ -625,11 +616,10 @@ - - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index af67c36..c8b9c6c 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -814,57 +814,9 @@ const API = (() => { } catch {} } - // ---------------------------------------------------------- - // BILD-KOMPRESSION (Client-Side vor Upload) - // ---------------------------------------------------------- - // iPhone-Fotos sind 4-12 MB — vor Upload auf max. 2000px / JPEG q=0.85 - // skalieren. HEIC/HEIF unverändert lassen (Browser-Canvas kann sie nicht - // decoden, Backend hat eigene HEIC-Konvertierung). Nicht-Bilder unverändert. - async function compressImage(file, maxSize = 2000, quality = 0.85) { - try { - if (!file || !(file instanceof File || file instanceof Blob)) return file; - const type = (file.type || '').toLowerCase(); - if (!type.startsWith('image/')) return file; - if (type === 'image/heic' || type === 'image/heif') return file; - if (type === 'image/gif') return file; // GIF-Animation nicht kaputt skalieren - if (file.size < 500_000) return file; // <500KB: lohnt sich nicht - - const img = await createImageBitmap(file); - try { - const longest = Math.max(img.width, img.height); - const scale = Math.min(1, maxSize / longest); - // Nur skalieren wenn Bild wirklich größer ist; bei scale=1 trotzdem als JPEG - // neu kodieren — spart bei iPhone-Originalen oft trotzdem viel (EXIF, weniger Qualität). - const w = Math.round(img.width * scale); - const h = Math.round(img.height * scale); - - const canvas = document.createElement('canvas'); - canvas.width = w; canvas.height = h; - const ctx = canvas.getContext('2d'); - if (!ctx) return file; - ctx.drawImage(img, 0, 0, w, h); - - const blob = await new Promise(res => canvas.toBlob(res, 'image/jpeg', quality)); - if (!blob || blob.size >= file.size) return file; // Kompression hat nichts gebracht - - const newName = (file.name || 'photo.jpg').replace(/\.(heic|heif|png|webp|jpeg|jpg)$/i, '.jpg'); - const finalName = /\.jpe?g$/i.test(newName) ? newName : (newName + '.jpg'); - return new File([blob], finalName, { type: 'image/jpeg', lastModified: Date.now() }); - } finally { - // ImageBitmap-Ressourcen freigeben (wo unterstützt) - if (typeof img.close === 'function') img.close(); - } - } catch { - // Bei jedem Fehler (z.B. createImageBitmap auf HEIC) — original zurück - return file; - } - } - // Auch global verfügbar, damit Seiten-Module ihn direkt nutzen können - if (typeof window !== 'undefined') window.compressImage = compressImage; - // Öffentliche API return { - get, post, put, patch, del, upload, swCacheDelete, compressImage, + get, post, put, patch, del, upload, swCacheDelete, auth, dogs, diary, health, tieraerzte, healthDocs, poison, places, routes, walks, events, sitting, forum, lost, knigge, weather, push, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 16cdbbe..1bec4db 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,10 +3,8 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1090'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1070'; // ← 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; const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen. // Flag MUSS vor replaceState gesetzt werden — index.html liest es danach. diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js deleted file mode 100644 index 20951b3..0000000 --- a/backend/static/js/offline-indicator.js +++ /dev/null @@ -1,263 +0,0 @@ -/* ============================================================ - BAN YARO — Offline-Bereitschafts-Anzeige IM Welten-FAB - Färbt die 5 Pfoten-Pfade je nach Cache-Stand grün: - 1 = App-Shell · 2 = Wichtige Seiten · 3 = Hund-/Tagebuchdaten - 4 = Karten-Tiles · 5 = Training & Wissen - ============================================================ */ - -window.OfflineIndicator = (() => { - 'use strict'; - - // Cache-Namen dynamisch finden — robust gegen Versions-Updates - const CACHE_TILES = 'ban-yaro-tiles-v1'; - const CACHE_API = 'ban-yaro-api-v1'; - const TILE_MIN = 50; - - async function _staticCache() { - const names = await caches.keys(); - const found = names.find(n => /^by-v\d+-static$/.test(n)); - return found ? await caches.open(found) : null; - } - - const CHECKS = [ - { step: 1, title: 'App-Grundgerüst', - detail: 'CSS, Layout und Hauptmodule — die Basis', - probe: async () => { - const c = await _staticCache(); - if (!c) return false; - const urls = (await c.keys()).map(r => r.url); - return urls.some(u => u.includes('/css/design-system.css')) - && urls.some(u => u.includes('/js/app.js')); - } }, - - { step: 2, title: 'Wichtige Seiten', - detail: 'Tagebuch, Gesundheit, Karte, Gassi, Erste Hilfe, Notizen, Ausgaben, Routen', - probe: async () => { - const c = await _staticCache(); - if (!c) return false; - const want = ['diary.js','health.js','map.js','walks.js','erste-hilfe.js', - 'notes.js','expenses.js','routes.js']; - const urls = (await c.keys()).map(r => r.url); - const have = want.filter(name => urls.some(u => u.includes('/js/pages/' + name))); - return have.length >= want.length - 1; // 1 Toleranz (falls einzelner Fetch fehlschlug) - } }, - - { step: 3, title: 'Hund-Daten', - detail: 'Profil, Tagebuch und Gesundheit', - probe: async () => { - const c = await caches.open(CACHE_API).catch(() => null); - if (!c) return false; - const urls = (await c.keys()).map(r => r.url); - const hasProfile = urls.some(u => /\/api\/dogs\/\d+(\?|$)/.test(u)) - || urls.some(u => /\/api\/dogs\/\d+\/welcome-dashboard/.test(u)); - const hasDiary = urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u)); - const hasHealth = urls.some(u => /\/api\/dogs\/\d+\/health/.test(u)); - // Profil + mindestens eine Datenquelle (Tagebuch oder Gesundheit) - return hasProfile && (hasDiary || hasHealth); - } }, - - { step: 4, title: 'Weitere Listen', - detail: 'Ausgaben, Routen, Notizen', - probe: async () => { - const c = await caches.open(CACHE_API).catch(() => null); - if (!c) return false; - const urls = (await c.keys()).map(r => r.url); - return urls.some(u => u.includes('/api/expenses')) - && urls.some(u => u.includes('/api/routes')) - && urls.some(u => u.includes('/api/notes')); - } }, - - { step: 5, title: 'Karten-Kacheln', - detail: `Mindestens ${TILE_MIN} OSM-Tiles im Umkreis`, - probe: async () => { - const c = await caches.open(CACHE_TILES).catch(() => null); - if (!c) return false; - return (await c.keys()).length >= TILE_MIN; - } }, - ]; - - // Tile-Prefetch-Konfiguration - const TILE_PREFETCH = [ - { zoom: 14, radius: 3 }, // 7x7 = 49 Tiles im Nahbereich - { zoom: 13, radius: 1 }, // 3x3 = 9 Tiles für Übersicht - ]; - - let _fab = null; - - async function refresh() { - _fab = document.getElementById('worlds-fab'); - if (!_fab || !('caches' in window)) return null; - - const results = await Promise.all(CHECKS.map(async c => { - try { return { ...c, ok: await c.probe() }; } - catch { return { ...c, ok: false }; } - })); - - _fab.querySelectorAll('.paw-elem').forEach(el => { - const step = Number(el.dataset.step); - const isOk = results.find(r => r.step === step)?.ok; - el.classList.toggle('filled', !!isOk); - }); - - const score = results.filter(r => r.ok).length; - _fab.setAttribute('data-offline-score', `${score}/5`); - return { score, results }; - } - - // Optional aufrufbar: zeigt das Status-Modal mit Nachlade-Button - async function openStatus() { - const data = await refresh(); - if (!data) return; - const { score, results } = data; - const missing = results.filter(r => !r.ok); - const rows = results.map(r => ` -
-
${r.ok ? '✓' : '○'}
-
-
${r.title}
-
${r.detail}
-
-
- `).join(''); - - UI.modal.open({ - title: `🐾 Offline-Bereitschaft ${score}/5`, - body: ` -

- ${missing.length === 0 - ? 'Voll offline-fähig — Tagebuch, Karte und Daten funktionieren auch ohne Internet.' - : 'Je grüner deine Pfote im FAB, desto mehr klappt offline. Fehlende Inhalte werden beim nächsten Online-Aufruf automatisch geladen.'} -

- ${rows} - `, - footer: ` -
- ${missing.length ? `` : ''} - -
- `, - }); - - document.getElementById('offline-fill-btn')?.addEventListener('click', async () => { - const btn = document.getElementById('offline-fill-btn'); - btn.disabled = true; btn.textContent = 'Lade …'; - await _fetchMissing(missing); - UI.modal.close(); - UI.toast.success('Offline-Inhalte aktualisiert.'); - refresh(); - }); - } - - async function _fetchMissing(missing) { - const tasks = []; - for (const m of missing) { - if (m.step === 2) { - ['diary.js','map.js','walks.js','erste-hilfe.js','notes.js','expenses.js','routes.js'].forEach(p => - tasks.push(fetch(`/js/pages/${p}?v=${window.APP_VER}`).catch(() => {}))); - } else if (m.step === 3) { - const dogId = window._appState?.activeDog?.id; - if (dogId) { - tasks.push(fetch(`/api/dogs/${dogId}`).catch(() => {})); - tasks.push(fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {})); - tasks.push(fetch(`/api/dogs/${dogId}/health`).catch(() => {})); - } - } else if (m.step === 4) { - tasks.push(fetch('/api/expenses').catch(() => {})); - tasks.push(fetch('/api/routes').catch(() => {})); - tasks.push(fetch('/api/notes').catch(() => {})); - } else if (m.step === 5) { - await _prefetchTiles(); - } - } - await Promise.all(tasks); - } - - // ---------------------------------------------------------- - // Tile-URL-Berechnung (OSM, Subdomain 'a') - // ---------------------------------------------------------- - function _tile(lat, lon, z) { - const n = Math.pow(2, z); - const x = Math.floor((lon + 180) / 360 * n); - const latRad = lat * Math.PI / 180; - const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1/Math.cos(latRad)) / Math.PI) / 2 * n); - return { x, y }; - } - function _tileUrls(lat, lon, zoom, radius) { - const center = _tile(lat, lon, zoom); - const out = []; - for (let dx = -radius; dx <= radius; dx++) { - for (let dy = -radius; dy <= radius; dy++) { - out.push(`https://a.tile.openstreetmap.org/${zoom}/${center.x + dx}/${center.y + dy}.png`); - } - } - return out; - } - - // Tile-Prefetch im Umkreis der aktuellen GPS-Position (nur wenn Permission schon da) - async function _prefetchTiles() { - if (!navigator.serviceWorker?.controller) return; - if (!navigator.permissions || !navigator.geolocation) return; - try { - const perm = await navigator.permissions.query({ name: 'geolocation' }); - if (perm.state !== 'granted') return; // kein Popup wenn nicht schon erlaubt - const pos = await new Promise(res => - navigator.geolocation.getCurrentPosition(p => res(p), () => res(null), { timeout: 5000 })); - if (!pos) return; - const urls = []; - TILE_PREFETCH.forEach(({ zoom, radius }) => - urls.push(..._tileUrls(pos.coords.latitude, pos.coords.longitude, zoom, radius))); - navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls }); - } catch {} - } - - // Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte - function _prefetchPages() { - ['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost'] - .forEach(p => fetch(`/js/pages/${p}.js?v=${window.APP_VER}`).catch(() => {})); - } - - // Daten-Prefetch: Listen die offline brauchbar sein müssen, - // auch wenn der User die Seiten noch nie geöffnet hat - function _prefetchData() { - fetch('/api/expenses').catch(() => {}); - fetch('/api/routes').catch(() => {}); - fetch('/api/notes').catch(() => {}); - const dogId = window._appState?.activeDog?.id; - if (dogId) { - fetch(`/api/dogs/${dogId}`).catch(() => {}); - fetch(`/api/dogs/${dogId}/health`).catch(() => {}); - fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {}); - } - } - - function init() { - refresh(); - _prefetchPages(); - _prefetchTiles(); - _prefetchData(); - - // Mehrere Retries für hund-spezifische Daten — _appState.activeDog wird oft - // erst nach Login/Hunde-Load gesetzt, manchmal mehrere Sekunden nach Init - [2_000, 5_000, 10_000, 20_000].forEach(delay => { - setTimeout(() => { _prefetchData(); refresh(); }, delay); - }); - - if (navigator.serviceWorker) { - navigator.serviceWorker.addEventListener('message', e => { - if (e?.data?.type === 'CACHE_UPDATE') refresh(); - if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh(); - }); - } - setInterval(refresh, 60_000); - } - - return { init, refresh, openStatus }; -})(); - -if (document.readyState !== 'loading') { - window.OfflineIndicator.init(); -} else { - document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init()); -} diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 311f104..b7acbf5 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -8,7 +8,6 @@ window.Page_admin = (() => { let _container = null; let _appState = null; let _tab = 'uebersicht'; - let _delegationAttached = null; // WeakRef-Ersatz: zuletzt mit Delegation versehener Container const TABS = [ { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, @@ -72,43 +71,20 @@ window.Page_admin = (() => { _container.querySelector('#adm-tabs') ?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2)); - - // Event-Delegation: Listener EINMAL pro Container-Instanz setzen - // (innerHTML überschreibt zwar das DOM, aber bei jedem init() würden ohne - // Flag erneut N=18 Tab-Listener akkumuliert werden) - if (_delegationAttached !== _container) { - _container.addEventListener('click', _onContainerClick); - _delegationAttached = _container; - } + _container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => { + btn.addEventListener('click', () => { + _tab = btn.dataset.tab; + _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === _tab) + ); + _renderTab(); + }); + }); _renderActionItems(); _renderTab(); } - // Delegation-Handler für Tab-Buttons + Action-Item-Buttons - function _onContainerClick(e) { - // Tab-Button (#adm-tabs .by-tab) - const tabBtn = e.target.closest('#adm-tabs .by-tab'); - if (tabBtn && _container.contains(tabBtn)) { - _tab = tabBtn.dataset.tab; - _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => - b.classList.toggle('active', b.dataset.tab === _tab) - ); - _renderTab(); - return; - } - // Action-Item-Button ([data-action-tab]) - const actionBtn = e.target.closest('[data-action-tab]'); - if (actionBtn && _container.contains(actionBtn)) { - _tab = actionBtn.dataset.actionTab; - _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => - b.classList.toggle('active', b.dataset.tab === _tab) - ); - _renderTab(); - return; - } - } - async function _renderActionItems() { const el = _container.querySelector('#adm-action-items'); if (!el) return; @@ -158,7 +134,15 @@ window.Page_admin = (() => {
`; - // Klicks auf [data-action-tab] werden zentral via _onContainerClick (Delegation) behandelt + el.querySelectorAll('[data-action-tab]').forEach(btn => { + btn.addEventListener('click', () => { + _tab = btn.dataset.actionTab; + _container.querySelectorAll('#adm-tabs .by-tab').forEach(b => + b.classList.toggle('active', b.dataset.tab === _tab) + ); + _renderTab(); + }); + }); } async function _renderTab() { @@ -836,13 +820,7 @@ window.Page_admin = (() => {
🗺 ${u.route_count} Routen · ${u.total_km} km · 📍 ${u.poi_count} POIs -
-
- ${u.last_seen - ? '🟢 zuletzt aktiv ' + new Date(u.last_seen).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) - : u.last_login - ? '🔵 zuletzt eingeloggt ' + new Date(u.last_login).toLocaleString('de-DE', {day:'2-digit',month:'2-digit',year:'numeric',hour:'2-digit',minute:'2-digit'}) - : '⚪ nie aktiv'} + ${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''}
@@ -3569,15 +3547,6 @@ window.Page_admin = (() => {
- ${r.existing_invoice_id ? ` - ` : ` `} + ` - : `
- - -
`} - ${canCancel ? ` - ` : ''} -
+ + `, }); - // Bei gesperrten Rechnungen (sent/paid/cancelled) alle Eingaben & Action-Buttons readonly - if (isLocked) { - setTimeout(() => { - const form = document.getElementById(id); - if (!form) return; - form.querySelectorAll('input, textarea').forEach(inp => { inp.disabled = true; }); - // "+ Position hinzufügen" und Item-Lösch-Buttons verstecken - const addBtn = document.getElementById(`${id}-add-item`); - if (addBtn) addBtn.style.display = 'none'; - form.querySelectorAll('.inv-item-remove').forEach(b => { b.style.display = 'none'; }); - }, 0); - } - - // Stornieren — schließt dieses Modal, öffnet den Storno-Dialog - document.getElementById(`${id}-cancel-invoice`)?.addEventListener('click', () => { - // _openStornoModal ruft intern UI.modal.open() → schließt dieses Modal automatisch - _openStornoModal(invoiceId, invoiceNumber || `#${invoiceId}`, reload); - }); - // Items-Container und Hilfsfunktionen const itemsContainer = document.getElementById(`${id}-items`); const previewEl = document.getElementById(`${id}-preview`); @@ -4147,7 +4046,6 @@ window.Page_admin = (() => { // Form Submit document.getElementById(id)?.addEventListener('submit', async e => { e.preventDefault(); - if (isLocked) return; // gesperrte Rechnung — Submit ignorieren (Button ist eh ausgeblendet) const fd = new FormData(e.target); const items = []; itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index cdd67be..d15c9b5 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1437,33 +1437,25 @@ window.Page_diary = (() => { wrap.innerHTML = grid; wrap.querySelectorAll('.diary-media-thumb-del').forEach(btn => { btn.addEventListener('click', async () => { - const wrap2 = btn.closest('.diary-media-thumb-wrap') || btn.parentElement; + const wrap2 = btn.closest('.diary-media-thumb-wrap'); const mediaId = btn.dataset.mediaId ? parseInt(btn.dataset.mediaId) : null; const isLegacy = !!btn.dataset.legacy; btn.disabled = true; - let alreadyGone = false; try { if (mediaId != null) { await API.diary.deleteMediaItem(_appState.activeDog.id, entry.id, mediaId); + // aus entry.media_items entfernen + if (entry.media_items) entry.media_items = entry.media_items.filter(m => m.id !== mediaId); } else if (isLegacy) { await API.diary.deleteMedia(_appState.activeDog.id, entry.id); + entry.media_url = null; } + wrap2.remove(); + UI.toast.success('Medium entfernt.'); } catch (e) { - if (e?.status === 404) { - alreadyGone = true; // serverseitig schon weg → trotzdem lokal aufräumen - } else { - btn.disabled = false; - UI.toast.error(e.message || 'Fehler beim Löschen.'); - return; - } + btn.disabled = false; + UI.toast.error(e.message || 'Fehler beim Löschen.'); } - if (mediaId != null && entry.media_items) { - entry.media_items = entry.media_items.filter(m => m.id !== mediaId); - } else if (isLegacy) { - entry.media_url = null; - } - if (wrap2) wrap2.remove(); - UI.toast.success(alreadyGone ? 'Verwaisten Eintrag aufgeräumt.' : 'Medium entfernt.'); }); }); // Stern-Buttons im Edit-Formular @@ -1730,37 +1722,29 @@ window.Page_diary = (() => { }; async function _uploadNewFiles(entryId) { - const total = _newFiles.length; - const saveBtn = document.querySelector('button[form="diary-form"]'); - let done = 0; - if (saveBtn) saveBtn.textContent = `0 von ${total} hochgeladen…`; - - const results = await Promise.all(_newFiles.map(async file => { - // Bild-Kompression vor Upload (HEIC/Video/<500KB werden unverändert durchgereicht) - const toUpload = await API.compressImage(file); - const formData = new FormData(); - formData.append('file', toUpload); + let failCount = 0; + const uploaded = []; + let exifGps = null; + for (const file of _newFiles) { try { + const formData = new FormData(); + formData.append('file', file); const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData); - if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`; - return { ok: true, m }; + uploaded.push(m); + if (m.exif_lat != null && m.exif_lon != null && !exifGps) { + exifGps = { lat: m.exif_lat, lon: m.exif_lon }; + } } catch { - if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`; - return { ok: false }; + failCount++; } - })); - - const uploaded = results.filter(r => r.ok).map(r => r.m); - const failCount = results.filter(r => !r.ok).length; - const exifGps = results.find(r => r.ok && r.m.exif_lat != null)?.m; - + } if (failCount > 0) { UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`); } if (exifGps) { UI.toast.success(`📍 Standort aus Foto-GPS übernommen`); } - return { uploaded, exifGps: exifGps ? { lat: exifGps.exif_lat, lon: exifGps.exif_lon } : null }; + return { uploaded, exifGps }; } if (isEdit) { diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 2679ed3..95aa123 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -762,10 +762,8 @@ window.Page_dog_profile = (() => { const file = e.target.files[0]; if (!file) return; try { - // Client-Side-Kompression vor Upload (HEIC bleibt unverändert) - const toUpload = await API.compressImage(file); const fd = new FormData(); - fd.append('file', toUpload); + fd.append('file', file); const result = await API.dogs.uploadPhoto(dog.id, fd); // Position zurücksetzen await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0); @@ -1386,10 +1384,8 @@ window.Page_dog_profile = (() => { // Foto hochladen wenn gewählt if (fotoFile) { try { - // Client-Side-Kompression vor Upload - const toUpload = await API.compressImage(fotoFile); const fd = new FormData(); - fd.append('file', toUpload); + fd.append('file', fotoFile); const result = await API.dogs.uploadPhoto(saved.id, fd); saved.foto_url = result.foto_url; _appState.activeDog = { ...saved }; diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 61eb95c..bf68615 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -1263,9 +1263,8 @@ window.Page_health = (() => { if (!saved.media_items) saved.media_items = []; for (const f of files) { try { - const toUpload = await API.compressImage(f); const fd = new FormData(); - fd.append('file', toUpload); + fd.append('file', f); const res = await API.health.uploadMedia(dogId, saved.id, fd); saved.media_items.push({ id: res.id, url: res.url, media_type: res.media_type }); // Rückwärtskompatibilität: erste Datei auch als datei_url sichern @@ -2740,8 +2739,6 @@ window.Page_health = (() => { } await UI.asyncButton(btn, async () => { - // Client-Side-Kompression nur wenn Bild (PDFs etc. unverändert durchgereicht) - const toUpload = await API.compressImage(file); const formData = new FormData(); formData.append('dog_id', String(dog.id)); formData.append('typ', fd.typ); @@ -2749,7 +2746,7 @@ window.Page_health = (() => { formData.append('beschreibung', fd.beschreibung || ''); formData.append('datum', fd.datum || ''); if (fd.vet_id) formData.append('vet_id', fd.vet_id); - formData.append('file', toUpload); + formData.append('file', file); try { const doc = await API.healthDocs.upload(formData); diff --git a/backend/static/js/pages/lost.js b/backend/static/js/pages/lost.js index db23517..6f8fe0c 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -772,9 +772,8 @@ window.Page_lost = (() => { // Foto hochladen if (photoInput?.files[0]) { try { - const toUpload = await API.compressImage(photoInput.files[0]); const formData = new FormData(); - formData.append('file', toUpload); + formData.append('file', photoInput.files[0]); const media = await API.lost.uploadFoto(created.id, formData); created.foto_url = media.foto_url; } catch { diff --git a/backend/static/js/pages/poison.js b/backend/static/js/pages/poison.js index ca5aca3..150fddc 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -552,9 +552,8 @@ window.Page_poison = (() => { // Foto hochladen if (photoInput?.files[0]) { try { - const toUpload = await API.compressImage(photoInput.files[0]); const formData = new FormData(); - formData.append('file', toUpload); + formData.append('file', photoInput.files[0]); const media = await API.poison.uploadPhoto(created.id, formData); created.foto_url = media.foto_url; } catch { diff --git a/backend/static/js/pages/walks.js b/backend/static/js/pages/walks.js index fbf38f2..e0c6c40 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -628,10 +628,8 @@ window.Page_walks = (() => { document.getElementById('wd-photo-input')?.addEventListener('change', async function() { if (!this.files.length) return; const file = this.files[0]; - // Client-Side-Kompression vor Upload (HEIC bleibt unverändert) - const toUpload = await API.compressImage(file); const formData = new FormData(); - formData.append('file', toUpload); + formData.append('file', file); try { const photo = await API.walks.uploadPhoto(walk.id, formData); const grid = document.getElementById('wd-photos-grid'); diff --git a/backend/static/sw.js b/backend/static/sw.js index 6fce525..ae167cc 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,22 +4,19 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1090'; +const VER = '1070'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache // Prioritäts-Seiten: werden nach Install im Hintergrund gecacht (nicht blockierend) -// Diese Seiten MÜSSEN offline funktionieren — auch wenn der User sie noch nie geöffnet hat. const PRIORITY_PAGES = [ + '/js/pages/admin.js', + '/js/pages/erste-hilfe.js', '/js/pages/diary.js', - '/js/pages/health.js', '/js/pages/map.js', '/js/pages/walks.js', - '/js/pages/erste-hilfe.js', - '/js/pages/notes.js', - '/js/pages/expenses.js', '/js/pages/routes.js', '/js/pages/poison.js', '/js/pages/lost.js', @@ -35,7 +32,6 @@ const STATIC_ASSETS = [ `/js/ui.js?v=${VER}`, `/js/app.js?v=${VER}`, `/js/worlds.js?v=${VER}`, - `/js/offline-indicator.js?v=${VER}`, '/js/leaflet.markercluster.js', '/css/MarkerCluster.css', '/css/MarkerCluster.Default.css', @@ -136,26 +132,6 @@ function _isMediaUpload(request) { return (request.headers.get('Content-Type') || '').includes('multipart'); } -// Tile-Cache LRU: Eviction wenn zu viele Tiles drin sind -// cache.keys() liefert Insertion-Order — daher löschen wir vom Anfang. -// Bei jedem Tile-Add: trimTileCache() im Hintergrund (kein await vor respondWith). -const _TILE_MAX_ENTRIES = 500; -let _tileTrimRunning = false; -async function trimTileCache(maxEntries = _TILE_MAX_ENTRIES) { - if (_tileTrimRunning) return; // gleichzeitige Trims verhindern - _tileTrimRunning = true; - try { - const cache = await caches.open(CACHE_TILES); - const keys = await cache.keys(); - if (keys.length > maxEntries) { - const toDelete = keys.slice(0, keys.length - maxEntries); - await Promise.all(toDelete.map(k => cache.delete(k))); - } - } catch {} finally { - _tileTrimRunning = false; - } -} - // Welche GET-API-Endpoints sollen gecacht werden? const _CACHEABLE_GET = [ /^\/api\/dogs(\/\d+)?$/, @@ -175,7 +151,6 @@ const _CACHEABLE_GET = [ /^\/api\/walks/, /^\/api\/lost/, /^\/api\/expenses/, - /^\/api\/notes/, // Drei Welten — offline-fähig /^\/api\/streak\/\d+/, /^\/api\/forum\/threads/, @@ -340,18 +315,14 @@ self.addEventListener('fetch', event => { return; } - // OSM-Kartenkacheln: eigener persistenter Cache (Cache-First mit LRU-Eviction) + // OSM-Kartenkacheln: eigener persistenter Cache if (url.hostname.endsWith('tile.openstreetmap.org')) { event.respondWith( caches.open(CACHE_TILES).then(cache => cache.match(event.request).then(cached => { if (cached) return cached; return fetch(event.request).then(response => { - if (response.ok) { - cache.put(event.request, response.clone()) - .then(() => trimTileCache()) // im Hintergrund — blockiert respondWith nicht - .catch(() => {}); - } + if (response.ok) cache.put(event.request, response.clone()); return response; }); }) @@ -464,7 +435,6 @@ self.addEventListener('message', event => { function fetchBatch() { if (queue.length === 0) { source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: total, total }); - trimTileCache(); // nach Bulk-Vorausladen einmal trimmen return; } const batch = queue.splice(0, 8);