From c03884cb81ea40af77e69d8c03c17194f8d3abf3 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 06:30:36 +0200 Subject: [PATCH] =?UTF-8?q?Perf:=209=20Performance-Fixes=20=E2=80=94=20SW?= =?UTF-8?q?=20by-v1072?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Backend: - DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries - Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles) - diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per run_in_executor → blockiert Event-Loop nicht mehr - scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True - social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt - alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter Frontend: - sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge) - admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener - api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen (diary, dog-profile×2, walks, poison, lost, health×2) Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html) --- backend/cache.py | 103 +++++++++++++++++++++++++ backend/database.py | 4 + backend/main.py | 2 +- backend/routes/alerts.py | 26 ++++++- backend/routes/breeder_photos.py | 44 +++++++---- backend/routes/diary.py | 30 ++++--- backend/routes/dogs.py | 20 ++++- backend/routes/help.py | 36 ++++++--- backend/routes/social.py | 31 +++++--- backend/routes/training.py | 17 +++- backend/routes/wiki.py | 33 ++++++-- backend/scheduler.py | 57 ++++++++++---- backend/static/index.html | 14 ++-- backend/static/js/api.js | 50 +++++++++++- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 52 ++++++++----- backend/static/js/pages/diary.js | 4 +- backend/static/js/pages/dog-profile.js | 8 +- backend/static/js/pages/health.js | 7 +- backend/static/js/pages/lost.js | 3 +- backend/static/js/pages/poison.js | 3 +- backend/static/js/pages/walks.js | 4 +- backend/static/sw.js | 31 +++++++- 23 files changed, 461 insertions(+), 120 deletions(-) create mode 100644 backend/cache.py diff --git a/backend/cache.py b/backend/cache.py new file mode 100644 index 0000000..29c471d --- /dev/null +++ b/backend/cache.py @@ -0,0 +1,103 @@ +"""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 7368eb7..00a5210 100644 --- a/backend/database.py +++ b/backend/database.py @@ -180,6 +180,8 @@ 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, @@ -272,6 +274,8 @@ 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 ed8aac2..8268dfd 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 = "1071" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1072" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/routes/alerts.py b/backend/routes/alerts.py index 4ffdcd0..0065d18 100644 --- a/backend/routes/alerts.py +++ b/backend/routes/alerts.py @@ -9,6 +9,7 @@ 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: @@ -20,15 +21,36 @@ 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 > ?", (now,) + """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) ).fetchall() lost = conn.execute( - "SELECT lat, lon FROM lost_dogs WHERE is_active=1" + """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) ).fetchall() # Letzten Standort des Users für geo-basierte Push-Filter speichern if user: diff --git a/backend/routes/breeder_photos.py b/backend/routes/breeder_photos.py index 554cb91..18eb085 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 +import os, logging, asyncio from database import db from auth import get_current_user, get_current_user_optional from media_utils import validate_upload, generate_preview @@ -112,27 +112,37 @@ 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 = generate_preview(raw_data, ext) + thumb_bytes = await loop.run_in_executor( + None, lambda: generate_preview(raw_data, ext) + ) thumb_path = None if thumb_bytes: thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp") - with open(thumb_path, "wb") as f: - f.write(thumb_bytes) + await loop.run_in_executor(None, lambda: _write_bytes(thumb_path, thumb_bytes)) - # 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) + # 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) # 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 6f6cd12..baf2586 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 +import os, uuid, json, math, logging, asyncio from fastapi import APIRouter, Depends, HTTPException, UploadFile, File from pydantic import BaseModel from typing import Optional @@ -684,7 +684,13 @@ 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)) - raw_data, ext = convert_media(raw_data, file.filename or "") + + # 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 "") + ) if not ext: ext = ".jpg" filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" @@ -692,17 +698,21 @@ 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) - with open(path, "wb") as f: - f.write(raw_data) + 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)) if media_type == "video": - extract_video_thumb(path) + await loop.run_in_executor(None, lambda: extract_video_thumb(path)) elif media_type == "image": - preview_bytes = generate_preview(raw_data, ext) + preview_bytes = await loop.run_in_executor( + None, lambda: generate_preview(raw_data, ext) + ) if preview_bytes: preview_path = os.path.splitext(path)[0] + "_preview.webp" - with open(preview_path, "wb") as f: - f.write(preview_bytes) + await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes)) media_url = f"/media/diary/{filename}" @@ -710,8 +720,8 @@ async def upload_media(dog_id: int, entry_id: int, exif_gps = None img_size = None if media_type == "image": - exif_gps = extract_gps_from_exif(raw_data) - img_size = get_image_size(raw_data) + 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)) with db() as conn: # sort_order = nächste freie Position diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index 45228ed..9cc2820 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -9,6 +9,20 @@ 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") @@ -1095,10 +1109,8 @@ 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" - with db() as conn: - alle_tipps = conn.execute( - "SELECT * FROM pflege_tipps ORDER BY kategorie, titel" - ).fetchall() + # Statische Tipps aus Cache (1h TTL) – Filterung passiert in-memory + alle_tipps = _load_all_pflege_tipps() # Relevante Tipps: kein Fell-Filter oder passend from datetime import date diff --git a/backend/routes/help.py b/backend/routes/help.py index 4c77018..6551e4d 100644 --- a/backend/routes/help.py +++ b/backend/routes/help.py @@ -5,10 +5,28 @@ 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 # ------------------------------------------------------------------ @@ -39,22 +57,17 @@ def get_help( is_admin = user and user.get("rolle") == "admin" show_all = all == 1 and is_admin - with db() as conn: - if show_all: + if show_all: + with db() as conn: rows = conn.execute( "SELECT id, kategorie, frage, antwort, sort_order, aktiv " "FROM help_articles " "ORDER BY kategorie, sort_order, id" ).fetchall() - 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() + return [dict(r) for r in rows] - return [dict(r) for r in rows] + # Öffentliche, aktive Artikel kommen aus dem Cache + return _load_active_help_articles() # ------------------------------------------------------------------ @@ -68,6 +81,7 @@ 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} @@ -85,6 +99,7 @@ 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} @@ -95,4 +110,5 @@ 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 494d9a2..1cf204d 100644 --- a/backend/routes/social.py +++ b/backend/routes/social.py @@ -1278,21 +1278,26 @@ 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 + # Übung wählen die noch nicht als Social-Post verwendet wurde. + # Per SQL: zuerst eine unbenutzte zufällig wählen, sonst Reset (irgendeine). with db() as conn: - 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() + 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() - 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: + if not row: raise HTTPException(404, "Keine Übungen gefunden.") - ex = dict(pool[0]) + ex = dict(row) 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])) @@ -1563,8 +1568,10 @@ 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()" + "SELECT * FROM pflege_tipps ORDER BY RANDOM() LIMIT 100" ).fetchall() # Noch nicht verwendete bevorzugen diff --git a/backend/routes/training.py b/backend/routes/training.py index aaaa855..078ceef 100644 --- a/backend/routes/training.py +++ b/backend/routes/training.py @@ -7,15 +7,16 @@ 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. # ------------------------------------------------------------------ -@router.get("/exercises") -async def get_exercises(): - """Alle Übungen aus der DB, gruppiert nach Tab-ID.""" +@ttl_cache(ttl=3600) +def _load_exercises_by_tab() -> dict: import json as _json CAT_TO_TAB = { 'Grundkommando': 'grundkommandos', @@ -33,7 +34,7 @@ async def get_exercises(): dauer, beschreibung, schritte, tipp FROM training_exercises ORDER BY kategorie, name """).fetchall() - by_tab = {} + by_tab: dict = {} for r in rows: tab = CAT_TO_TAB.get(r['kategorie'], r['kategorie'].lower().replace(' ', '-')) by_tab.setdefault(tab, []).append({ @@ -50,6 +51,12 @@ async def get_exercises(): }) 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) # ------------------------------------------------------------------ @@ -78,6 +85,8 @@ 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 56df55d..a05bb1b 100644 --- a/backend/routes/wiki.py +++ b/backend/routes/wiki.py @@ -10,6 +10,7 @@ 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") @@ -81,16 +82,34 @@ def _quiz_score(rasse: dict, params: dict) -> int: # ------------------------------------------------------------------ -# GET /api/wiki/stats — Seed-Status +# GET /api/wiki/stats — Seed-Status (1h TTL-Cache, statische Anzahl) # ------------------------------------------------------------------ -@router.get("/stats") -async def get_stats(): +@ttl_cache(ttl=3600) +def _wiki_stats() -> dict: 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) # ------------------------------------------------------------------ @@ -134,15 +153,13 @@ async def get_rassen( SELECT COUNT(*) as total FROM wiki_rassen {where} """, args).fetchone() - # 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() + # Alle Gruppen für Filter-Dropdown (gecached, 1h TTL) + gruppen = _wiki_gruppen() return { "breeds": [dict(r) for r in rows], "total": count_row["total"] if count_row else 0, - "gruppen": [r["gruppe"] for r in gruppen_rows], + "gruppen": gruppen, } diff --git a/backend/scheduler.py b/backend/scheduler.py index aee77b7..a6aef1f 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -24,12 +24,19 @@ _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, @@ -37,6 +44,7 @@ def start(): id="poison_archive", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) _scheduler.add_job( _job_weather_alert, @@ -44,6 +52,7 @@ def start(): id="weather_alert", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) _scheduler.add_job( _job_milestone_check, @@ -51,6 +60,7 @@ def start(): id="milestone_check", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) _scheduler.add_job( _job_import_events, @@ -58,6 +68,7 @@ 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 @@ -68,29 +79,32 @@ def start(): id="import_events_startup", replace_existing=True, ) - # Alle 4 Wochen Di 03:00 — Rassen aus TheDogAPI aktualisieren + # 1. des Monats 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, ) - # Alle 4 Wochen Di 04:00 — fehlende Rassen aus Wikidata ergänzen + # 1. des Monats 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:00 — Wöchentlicher Fortschritts-Lober + # Jeden Montag 09:05 — Wöchentlicher Fortschritts-Lober (staggered) _scheduler.add_job( _job_weekly_praise, - CronTrigger(day_of_week='mon', hour=9, minute=0), + CronTrigger(day_of_week='mon', hour=9, minute=5), id="weekly_praise", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) # Täglich 06:00 Uhr Status-Report per Mail _scheduler.add_job( @@ -99,6 +113,7 @@ def start(): id="status_report", replace_existing=True, misfire_grace_time=1800, + coalesce=True, ) # Täglich 12:00 — Moderation-Overdue-Check _scheduler.add_job( @@ -107,22 +122,25 @@ def start(): id="moderation_overdue", replace_existing=True, misfire_grace_time=1800, + coalesce=True, ) - # 1. Feb / Mai / Aug / Nov 07:00 — Quartalsbericht + # 1. Feb / Mai / Aug / Nov 07:10 — Quartalsbericht (staggered weg von 07:00) _scheduler.add_job( _job_quarterly_report, - CronTrigger(month="2,5,8,11", day=1, hour=7, minute=0), + CronTrigger(month="2,5,8,11", day=1, hour=7, minute=10), id="quarterly_report", replace_existing=True, misfire_grace_time=7200, + coalesce=True, ) - # Jeden Montag 07:00 — KI-Gesundheitsberichte (alle 2 Wochen) + # Jeden Montag 07:05 — KI-Gesundheitsberichte (staggered weg von 07:00) _scheduler.add_job( _job_ki_health_report, - CronTrigger(day_of_week='mon', hour=7, minute=0), + CronTrigger(day_of_week='mon', hour=7, minute=5), id="ki_health_report", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) # Täglich 06:30 — Wiederkehrende Ausgaben anlegen _scheduler.add_job( @@ -131,6 +149,7 @@ 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( @@ -139,6 +158,7 @@ def start(): id="hdm_winner", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) # Täglich 19:00 Uhr — Streak-Erinnerung _scheduler.add_job( @@ -147,22 +167,25 @@ def start(): id="streak_reminder", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) - # Täglich 08:00 Uhr — Tierfutter-Rückrufe prüfen (RASFF) + # Täglich 08:05 Uhr — Tierfutter-Rückrufe prüfen (RASFF) (staggered weg von 08:00) _scheduler.add_job( _job_recall_check, - CronTrigger(hour=8, minute=0), + CronTrigger(hour=8, minute=5), id="recall_check", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) - # Jeden Montag 08:00 Uhr — Neue Foto-Challenge anlegen + # Jeden Montag 08:10 Uhr — Neue Foto-Challenge anlegen (staggered weg von 08:00) _scheduler.add_job( _job_new_foto_challenge, - CronTrigger(day_of_week='mon', hour=8, minute=0), + CronTrigger(day_of_week='mon', hour=8, minute=10), id="new_foto_challenge", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) # Täglich 07:00 Uhr — Goldene Gassi-Stunde _scheduler.add_job( @@ -171,6 +194,7 @@ 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( @@ -179,6 +203,7 @@ 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( @@ -187,13 +212,16 @@ 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=0), + CronTrigger(hour=3, minute=15), id="subscription_check", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) _scheduler.add_job( _job_invoice_reminder, @@ -201,9 +229,10 @@ def start(): id="invoice_reminder", replace_existing=True, misfire_grace_time=3600, + coalesce=True, ) _scheduler.start() - 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).") + 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).") def stop(): diff --git a/backend/static/index.html b/backend/static/index.html index 7c87e70..80b7487 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -616,10 +616,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index c8b9c6c..af67c36 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -814,9 +814,57 @@ 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, + get, post, put, patch, del, upload, swCacheDelete, compressImage, 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 8b1c343..98b9316 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,7 +3,7 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1071'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1072'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt const IS_STAGING = location.hostname === 'staging.banyaro.app'; // Cache-Bust-Parameter nach Update-Reload sofort entfernen. diff --git a/backend/static/js/pages/admin.js b/backend/static/js/pages/admin.js index 4b644c6..108733a 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -8,6 +8,7 @@ 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' }, @@ -71,20 +72,43 @@ window.Page_admin = (() => { _container.querySelector('#adm-tabs') ?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2)); - _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(); - }); - }); + + // 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; + } _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; @@ -134,15 +158,7 @@ window.Page_admin = (() => { `; - 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(); - }); - }); + // Klicks auf [data-action-tab] werden zentral via _onContainerClick (Delegation) behandelt } async function _renderTab() { diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index 2af7303..a4e16e6 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1728,8 +1728,10 @@ window.Page_diary = (() => { 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', file); + formData.append('file', toUpload); try { const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData); if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`; diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index 95aa123..2679ed3 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -762,8 +762,10 @@ 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', file); + fd.append('file', toUpload); const result = await API.dogs.uploadPhoto(dog.id, fd); // Position zurücksetzen await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0); @@ -1384,8 +1386,10 @@ 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', fotoFile); + fd.append('file', toUpload); 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 bf68615..61eb95c 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -1263,8 +1263,9 @@ 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', f); + fd.append('file', toUpload); 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 @@ -2739,6 +2740,8 @@ 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); @@ -2746,7 +2749,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', file); + formData.append('file', toUpload); 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 6f8fe0c..db23517 100644 --- a/backend/static/js/pages/lost.js +++ b/backend/static/js/pages/lost.js @@ -772,8 +772,9 @@ 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', photoInput.files[0]); + formData.append('file', toUpload); 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 150fddc..ca5aca3 100644 --- a/backend/static/js/pages/poison.js +++ b/backend/static/js/pages/poison.js @@ -552,8 +552,9 @@ 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', photoInput.files[0]); + formData.append('file', toUpload); 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 e0c6c40..fbf38f2 100644 --- a/backend/static/js/pages/walks.js +++ b/backend/static/js/pages/walks.js @@ -628,8 +628,10 @@ 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', file); + formData.append('file', toUpload); 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 7394250..2eded82 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1071'; +const VER = '1072'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten @@ -132,6 +132,26 @@ 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+)?$/, @@ -315,14 +335,18 @@ self.addEventListener('fetch', event => { return; } - // OSM-Kartenkacheln: eigener persistenter Cache + // OSM-Kartenkacheln: eigener persistenter Cache (Cache-First mit LRU-Eviction) 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()); + if (response.ok) { + cache.put(event.request, response.clone()) + .then(() => trimTileCache()) // im Hintergrund — blockiert respondWith nicht + .catch(() => {}); + } return response; }); }) @@ -435,6 +459,7 @@ 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);