Perf: 9 Performance-Fixes — SW by-v1072

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)
This commit is contained in:
rene 2026-05-26 06:30:36 +02:00
parent 3abf974d29
commit c03884cb81
23 changed files with 461 additions and 120 deletions

103
backend/cache.py Normal file
View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -101,9 +101,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1071">
<link rel="stylesheet" href="/css/layout.css?v=1071">
<link rel="stylesheet" href="/css/components.css?v=1071">
<link rel="stylesheet" href="/css/design-system.css?v=1072">
<link rel="stylesheet" href="/css/layout.css?v=1072">
<link rel="stylesheet" href="/css/components.css?v=1072">
</head>
<body>
@ -616,10 +616,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1071"></script>
<script src="/js/ui.js?v=1071"></script>
<script src="/js/app.js?v=1071"></script>
<script src="/js/worlds.js?v=1071"></script>
<script src="/js/api.js?v=1072"></script>
<script src="/js/ui.js?v=1072"></script>
<script src="/js/app.js?v=1072"></script>
<script src="/js/worlds.js?v=1072"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

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

View file

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

View file

@ -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 = (() => {
</span>
</div>`;
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() {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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