From 3abf974d2900f725c3be4fa51cf6ef64d2ab4978 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 25 May 2026 20:26:58 +0200 Subject: [PATCH 01/20] Feature: Parallele Bild-Uploads, Heartbeat last_seen, Admin zuletzt aktiv, SW by-v1071 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Tagebuch: Bilder werden parallel hochgeladen (Promise.all), Button zeigt Fortschritt - Auth: /heartbeat Route ergänzt — aktualisiert last_seen alle 5 Min - Admin: last_seen + last_login in Nutzer-Liste angezeigt (🟢/🔵/⚪) - Bump SW by-v1071 --- backend/main.py | 2 +- backend/routes/admin.py | 2 +- backend/routes/auth.py | 7 +++++++ backend/static/index.html | 14 +++++++------- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 8 +++++++- backend/static/js/pages/diary.js | 32 +++++++++++++++++++------------- backend/static/sw.js | 2 +- 8 files changed, 44 insertions(+), 25 deletions(-) diff --git a/backend/main.py b/backend/main.py index d63ae64..ed8aac2 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 = "1070" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1071" # 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 c0ec221..a1c33a6 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.subscription_tier, + u.created_at, u.last_login, u.last_seen, 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, diff --git a/backend/routes/auth.py b/backend/routes/auth.py index 0ccc0cd..7ee2d03 100644 --- a/backend/routes/auth.py +++ b/backend/routes/auth.py @@ -479,3 +479,10 @@ 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/static/index.html b/backend/static/index.html index e8ea43a..7c87e70 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/app.js b/backend/static/js/app.js index 1bec4db..8b1c343 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 = '1070'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1071'; // ← 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 b7acbf5..4b644c6 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -820,7 +820,13 @@ window.Page_admin = (() => {
🗺 ${u.route_count} Routen · ${u.total_km} km · 📍 ${u.poi_count} POIs - ${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''} +
+
+ ${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'}
diff --git a/backend/static/js/pages/diary.js b/backend/static/js/pages/diary.js index d15c9b5..2af7303 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1722,29 +1722,35 @@ window.Page_diary = (() => { }; async function _uploadNewFiles(entryId) { - let failCount = 0; - const uploaded = []; - let exifGps = null; - for (const file of _newFiles) { + 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 => { + const formData = new FormData(); + formData.append('file', file); try { - const formData = new FormData(); - formData.append('file', file); const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData); - uploaded.push(m); - if (m.exif_lat != null && m.exif_lon != null && !exifGps) { - exifGps = { lat: m.exif_lat, lon: m.exif_lon }; - } + if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`; + return { ok: true, m }; } catch { - failCount++; + if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`; + return { ok: false }; } - } + })); + + 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 }; + return { uploaded, exifGps: exifGps ? { lat: exifGps.exif_lat, lon: exifGps.exif_lon } : null }; } if (isEdit) { diff --git a/backend/static/sw.js b/backend/static/sw.js index ae167cc..7394250 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 = '1070'; +const VER = '1071'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From c03884cb81ea40af77e69d8c03c17194f8d3abf3 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 06:30:36 +0200 Subject: [PATCH 02/20] =?UTF-8?q?Perf:=209=20Performance-Fixes=20=E2=80=94?= =?UTF-8?q?=20SW=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); From e5abdcab6293197d9187253e5dbafa8eec503868 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 13:38:11 +0200 Subject: [PATCH 03/20] =?UTF-8?q?Fix:=20Tagebuch=20Foto-L=C3=B6schen=20?= =?UTF-8?q?=E2=80=94=20null-crash=20+=20404-Cleanup,=20SW=20by-v1073?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 'null is not an object (wrap2.remove)': Wrapper-Div hat keine Klasse .diary-media-thumb-wrap → closest() lieferte null. Fallback auf btn.parentElement + Null-Check vor remove() - Bei 404 'Medium nicht gefunden' wird das verwaiste Foto jetzt trotzdem lokal aufgeräumt (entry.media_items + DOM), statt einen Error-Toast zu zeigen. Verwaiste Phantome verschwinden so beim ersten Lösch-Klick. --- backend/main.py | 2 +- backend/static/index.html | 14 +++++++------- backend/static/js/app.js | 2 +- backend/static/js/pages/diary.js | 24 ++++++++++++++++-------- backend/static/sw.js | 2 +- 5 files changed, 26 insertions(+), 18 deletions(-) diff --git a/backend/main.py b/backend/main.py index 8268dfd..484f7ae 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 = "1072" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1073" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 80b7487..4c56c46 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/app.js b/backend/static/js/app.js index 98b9316..29c16ff 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 = '1072'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1073'; // ← 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/diary.js b/backend/static/js/pages/diary.js index a4e16e6..cdd67be 100644 --- a/backend/static/js/pages/diary.js +++ b/backend/static/js/pages/diary.js @@ -1437,25 +1437,33 @@ 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'); + const wrap2 = btn.closest('.diary-media-thumb-wrap') || btn.parentElement; 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) { - btn.disabled = false; - UI.toast.error(e.message || 'Fehler beim Löschen.'); + 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; + } } + 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 diff --git a/backend/static/sw.js b/backend/static/sw.js index 2eded82..400c2a5 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 = '1072'; +const VER = '1073'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 5886e1b269a1d7b09b83101bc9fc45d939e59722 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 13:50:03 +0200 Subject: [PATCH 04/20] =?UTF-8?q?UX:=20Upgrades-Tab=20=E2=80=94=20Button?= =?UTF-8?q?=20zeigt=20vorhandene=20Rechnung=20an,=20SW=20by-v1074?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Backend: /admin/upgrade-requests liefert pro Request die offene Rechnung (id+number+status) per Subquery aus der invoices-Tabelle (status draft|sent → also nicht bezahlt, nicht storniert) - Frontend: Wenn schon eine Rechnung existiert, wird statt 'Rechnung erstellen' (orange) der Button 'Rechnung bearbeiten' (gelb, #eab308) gezeigt. Klick lädt die Rechnung und öffnet das Modal im Edit-Modus — kein doppeltes Anlegen, Nummerierung bleibt sauber. --- backend/main.py | 2 +- backend/routes/admin.py | 11 ++++++++++- backend/static/index.html | 14 ++++++------- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 34 +++++++++++++++++++++++++++++++- backend/static/sw.js | 2 +- 6 files changed, 53 insertions(+), 12 deletions(-) diff --git a/backend/main.py b/backend/main.py index 484f7ae..2010237 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 = "1073" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1074" # 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 a1c33a6..bfd02f8 100644 --- a/backend/routes/admin.py +++ b/backend/routes/admin.py @@ -1139,7 +1139,16 @@ 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 + 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 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/static/index.html b/backend/static/index.html index 4c56c46..eabd3e5 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/app.js b/backend/static/js/app.js index 29c16ff..a288144 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 = '1073'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1074'; // ← 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 108733a..9f5ee0b 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -3569,6 +3569,15 @@ window.Page_admin = (() => {
+ ${r.existing_invoice_id ? ` + ` : ` + `} - +
+
+ ${canCancel ? ` + ` : ''} +
+
+ + ${!isLocked ? `` : ''} +
+
`, }); + // 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`); @@ -4100,6 +4147,7 @@ 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/sw.js b/backend/static/sw.js index 4eaf361..50cba78 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 = '1074'; +const VER = '1075'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 280213c11ddfb1fd86ac63dad34a4450f8fb9d9c Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:03:09 +0200 Subject: [PATCH 06/20] =?UTF-8?q?UX:=20Rechnungs-Modal=20Footer=20f=C3=BCr?= =?UTF-8?q?=20Mobile,=20SW=20by-v1076?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Footer-Layout neu strukturiert — kein Umbruch-Chaos mehr: - Erste Zeile: Abbrechen | Speichern (Grid 1fr 1fr, gleich breit) oder bei sent/paid nur 'Schließen' volle Breite - Zweite Zeile (wenn vorhanden): Stornieren als volle Breite, ghost-Style mit rotem Rand — destruktive Aktion klar getrennt - Button-Text 'Änderungen speichern' → 'Speichern' (kein Abschneiden mehr auf iPhone) --- backend/main.py | 2 +- backend/static/index.html | 14 +++++++------- backend/static/js/app.js | 2 +- backend/static/js/pages/admin.js | 26 +++++++++++++------------- backend/static/sw.js | 2 +- 5 files changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/main.py b/backend/main.py index 5f2cd38..f22fee0 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 = "1075" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1076" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 277a855..8cef0ca 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/app.js b/backend/static/js/app.js index 011701e..553740b 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 = '1075'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1076'; // ← 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 e83dda2..311f104 100644 --- a/backend/static/js/pages/admin.js +++ b/backend/static/js/pages/admin.js @@ -4046,20 +4046,20 @@ window.Page_admin = (() => { `, footer: ` -
-
- ${canCancel ? ` - ` : ''} -
-
- - ${!isLocked ? `` + : `
+ + +
`} + ${canCancel ? ` + ` : ''} -
`, }); diff --git a/backend/static/sw.js b/backend/static/sw.js index 50cba78..114fc2d 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 = '1075'; +const VER = '1076'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 8097d2160561e6a6376226cbbb46b8771caaf4ef Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:16:57 +0200 Subject: [PATCH 07/20] Feature: Offline-Bereitschafts-Indikator (Pfote im Header), SW by-v1077 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Neue Pfote oben rechts im Header zeigt 5-stufige Offline-Bereitschaft (1 Ballen + 4 Zehen, je 20% — grau outline → grün gefüllt) - 5 Checks: App-Shell · Page-Module · Hund-/Tagebuchdaten · Karten- Tiles (≥50) · Foto-Previews - Klick öffnet Status-Modal mit Checkliste + 'Fehlende nachladen'- Button. Lädt aktiv: Page-Module per fetch, API-Daten für aktiven Hund, Tile-Cache per SW-Message CACHE_TILES, Diary-Foto-Previews - Refresh: alle 60s + bei SW CACHE_UPDATE-Message - Eigene offline-indicator.js (nicht im app.js mit reingequetscht); ins STATIC_ASSETS-Precache aufgenommen --- backend/main.py | 2 +- backend/static/css/components.css | 48 ++++++ backend/static/index.html | 34 +++- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 226 +++++++++++++++++++++++++ backend/static/sw.js | 3 +- 6 files changed, 305 insertions(+), 10 deletions(-) create mode 100644 backend/static/js/offline-indicator.js diff --git a/backend/main.py b/backend/main.py index f22fee0..d4f52c9 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 = "1076" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1077" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 41d4e27..24186fe 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8865,3 +8865,51 @@ svg.empty-state-icon { overflow: hidden; position: relative; } + +/* ============================================================ + Offline-Bereitschafts-Indikator (Pfote im Header) + 5 Pfade — Score 0 (grau) bis 5 (grün, gefüllt) + ============================================================ */ +.offline-paw .paw-elem { + color: var(--c-text-muted); + transition: stroke 0.5s ease, fill 0.5s ease; +} +.offline-paw .paw-elem.filled { + color: var(--c-success); + fill: var(--c-success); +} +#offline-indicator { + background: none; + border: none; + cursor: pointer; +} +#offline-indicator:hover .paw-elem { + opacity: 0.85; +} + +.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 8cef0ca..a85acae 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -325,6 +325,25 @@ Ban Yaro
+ ` : ''} + + + `, + }); + + 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(); + }); + } + + // ---------------------------------------------------------- + // Fehlende Inhalte aktiv nachladen + // ---------------------------------------------------------- + async function _fetchMissing(missing) { + const tasks = []; + for (const m of missing) { + if (m.step === 2) { + // Page-Module fetchen → SW cached sie automatisch + ['diary.js','map.js','walks.js','erste-hilfe.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(() => {})); + } + } else if (m.step === 4) { + // Karten-Tiles: SW per Message anstoßen + if (navigator.serviceWorker?.controller) { + const pos = await new Promise(res => + navigator.geolocation?.getCurrentPosition(p => res(p), () => res(null), { timeout: 4000 })); + if (pos) { + navigator.serviceWorker.controller.postMessage({ + type: 'CACHE_TILES', + lat: pos.coords.latitude, + lon: pos.coords.longitude, + zoom: 14, + radius: 2, + }); + } + } + } else if (m.step === 5) { + const dogId = window._appState?.activeDog?.id; + if (dogId) { + try { + const entries = await fetch(`/api/dogs/${dogId}/diary?limit=10`).then(r => r.json()); + (entries || []).slice(0, 10).forEach(e => { + if (e.cover_url) tasks.push(fetch(e.cover_url).catch(() => {})); + (e.media_items || []).slice(0, 3).forEach(m => { + if (m.url) tasks.push(fetch(m.url).catch(() => {})); + }); + }); + } catch {} + } + } + } + await Promise.all(tasks); + } + + // ---------------------------------------------------------- + // Init + // ---------------------------------------------------------- + function init() { + _btn = document.getElementById('offline-indicator'); + if (!_btn) return; + _svg = _btn.querySelector('.offline-paw'); + _btn.addEventListener('click', _openModal); + refresh(); + + // bei SW-Updates und alle 60s neu prüfen + if (navigator.serviceWorker) { + navigator.serviceWorker.addEventListener('message', e => { + if (e?.data?.type === 'CACHE_UPDATE') refresh(); + }); + } + setInterval(refresh, 60_000); + } + + return { init, refresh }; +})(); + +if (document.readyState !== 'loading') { + window.OfflineIndicator.init(); +} else { + document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init()); +} diff --git a/backend/static/sw.js b/backend/static/sw.js index 114fc2d..9e0ba5a 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 = '1076'; +const VER = '1077'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten @@ -32,6 +32,7 @@ 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', From 776641fa659f0dbb6d58eee9d97a22158dcdf770 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:18:47 +0200 Subject: [PATCH 08/20] Fix: Offline-Indicator Cache-Namen + Step-5-Check, SW by-v1078 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - CACHE_API hieß bei mir 'by-api', tatsächlich aber 'ban-yaro-api-v1' → korrigiert, sonst hätte step 3+5 nie grün werden können - Step 5 prüfte auf gecachte Diary-Foto-Previews — die werden vom SW aber gar nicht gecacht (nur API-Routen sind in _CACHEABLE_GET). Stattdessen jetzt 'Training & Wissen' (training/exercises + wiki/rassen) — ist im SW-Cache abgedeckt und passt zur WELT-Welt - _fetchMissing für Step 5 entsprechend angepasst --- backend/main.py | 2 +- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 26 +++++++++----------------- backend/static/sw.js | 2 +- 5 files changed, 20 insertions(+), 28 deletions(-) diff --git a/backend/main.py b/backend/main.py index d4f52c9..00b602e 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 = "1077" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1078" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index a85acae..d26e715 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -635,11 +635,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index cd1b1c3..45215d2 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 = '1077'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1078'; // ← 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/offline-indicator.js b/backend/static/js/offline-indicator.js index 0d5b9a7..881a9f6 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -10,7 +10,7 @@ window.OfflineIndicator = (() => { // Cache-Namen — müssen mit sw.js übereinstimmen const CACHE_STATIC = `by-v${(window.APP_VER || '0')}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; - const CACHE_API = 'by-api'; + const CACHE_API = 'ban-yaro-api-v1'; const TILE_MIN = 50; // Mindest-Tiles für Stufe 4 // 5 Offline-Bereitschafts-Checks, in Reihenfolge der Pfoten-Stufen @@ -51,13 +51,14 @@ window.OfflineIndicator = (() => { return keys.length >= TILE_MIN; } }, - { step: 5, title: 'Tagebuch-Fotos', - detail: 'Vorschau-Bilder der letzten Einträge', + { step: 5, title: 'Training & Wissen', + detail: 'Übungen, Wiki-Rassen, Wetter — Welt-Inhalte', probe: async () => { const c = await caches.open(CACHE_API).catch(() => null); if (!c) return false; - const keys = await c.keys(); - return keys.some(r => r.url.includes('/data/diary/') && r.url.includes('_preview')); + const urls = (await c.keys()).map(r => r.url); + return urls.some(u => u.includes('/api/training/exercises')) + && urls.some(u => u.includes('/api/wiki/rassen')); } }, ]; @@ -180,18 +181,9 @@ window.OfflineIndicator = (() => { } } } else if (m.step === 5) { - const dogId = window._appState?.activeDog?.id; - if (dogId) { - try { - const entries = await fetch(`/api/dogs/${dogId}/diary?limit=10`).then(r => r.json()); - (entries || []).slice(0, 10).forEach(e => { - if (e.cover_url) tasks.push(fetch(e.cover_url).catch(() => {})); - (e.media_items || []).slice(0, 3).forEach(m => { - if (m.url) tasks.push(fetch(m.url).catch(() => {})); - }); - }); - } catch {} - } + tasks.push(fetch('/api/training/exercises').catch(() => {})); + tasks.push(fetch('/api/wiki/rassen?limit=50').catch(() => {})); + tasks.push(fetch('/api/weather').catch(() => {})); } } await Promise.all(tasks); diff --git a/backend/static/sw.js b/backend/static/sw.js index 9e0ba5a..54ba328 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 = '1077'; +const VER = '1078'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 06b91dc54b5624b0e5fe084a92aee825a9666ca1 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:24:45 +0200 Subject: [PATCH 09/20] Fix: Offline-Pfote als schwebendes Element (Welten verstecken Header), SW by-v1079 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Header (#app-header) ist in den Welten per 'display:none !important' ausgeblendet (Welten übernehmen Navigation). Mein Pfötchen saß da drin und war genau dort unsichtbar wo es sichtbar sein sollte. - Button aus dem Header rausgeholt, am Ende vom body als schwebendes Element platziert (position:fixed; top-right; z-index:9000) - Eigener Stil: 40px runder Glas-Hintergrund, blur-Effekt, leichter Schatten — passt zur FAB-Optik unten rechts - Dark-Mode Hintergrund: dunkles Glas - Sichtbar in allen Welten und auf allen Seiten (auch wo Header da ist — sitzt daneben) - 'hidden'-Default raus, Element ist sofort sichtbar (nur Färbung wartet auf refresh()) --- backend/main.py | 2 +- backend/static/css/components.css | 37 ++++++++++++++----- backend/static/index.html | 49 ++++++++++++-------------- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 3 +- backend/static/sw.js | 2 +- 6 files changed, 54 insertions(+), 41 deletions(-) diff --git a/backend/main.py b/backend/main.py index 00b602e..95ed96c 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 = "1078" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1079" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 24186fe..3127ac3 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8867,9 +8867,36 @@ svg.empty-state-icon { } /* ============================================================ - Offline-Bereitschafts-Indikator (Pfote im Header) + Offline-Bereitschafts-Indikator — schwebend oben rechts 5 Pfade — Score 0 (grau) bis 5 (grün, gefüllt) ============================================================ */ +#offline-indicator { + position: fixed; + top: calc(env(safe-area-inset-top, 0px) + 8px); + right: 12px; + width: 40px; + height: 40px; + border-radius: 50%; + background: rgba(255,255,255,0.85); + backdrop-filter: blur(6px); + -webkit-backdrop-filter: blur(6px); + border: 1px solid rgba(0,0,0,0.08); + box-shadow: 0 2px 8px rgba(0,0,0,0.12); + display: flex; + align-items: center; + justify-content: center; + padding: 0; + cursor: pointer; + z-index: 9000; /* unter Modals (~9999), über allem anderen */ + transition: transform 0.12s, box-shadow 0.12s; +} +[data-theme="dark"] #offline-indicator { + background: rgba(31,41,55,0.85); + border-color: rgba(255,255,255,0.08); +} +#offline-indicator:active { transform: scale(0.92); } +#offline-indicator .offline-paw { width: 24px; height: 24px; } + .offline-paw .paw-elem { color: var(--c-text-muted); transition: stroke 0.5s ease, fill 0.5s ease; @@ -8878,14 +8905,6 @@ svg.empty-state-icon { color: var(--c-success); fill: var(--c-success); } -#offline-indicator { - background: none; - border: none; - cursor: pointer; -} -#offline-indicator:hover .paw-elem { - opacity: 0.85; -} .offline-status-row { display: flex; diff --git a/backend/static/index.html b/backend/static/index.html index d26e715..dd16b3c 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -325,25 +325,6 @@ Ban Yaro
- + @@ -635,11 +630,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 45215d2..87aec8c 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 = '1078'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1079'; // ← 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/offline-indicator.js b/backend/static/js/offline-indicator.js index 881a9f6..36d90c4 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -71,7 +71,7 @@ window.OfflineIndicator = (() => { // ---------------------------------------------------------- async function refresh() { if (!_btn) return; - if (!('caches' in window)) { _btn.classList.add('hidden'); return; } + if (!('caches' in window)) { _btn.style.display = 'none'; return; } const results = await Promise.all(CHECKS.map(async c => { try { return { ...c, ok: await c.probe() }; } @@ -93,7 +93,6 @@ window.OfflineIndicator = (() => { }); _btn.title = `Offline-Bereitschaft: ${score} von 5`; _btn.setAttribute('aria-label', `Offline-Bereitschaft: ${score} von 5`); - _btn.classList.remove('hidden'); } // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 54ba328..711fce0 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 = '1078'; +const VER = '1079'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 521b7b6bee1d81ea1fcf98f51116bda9ebe027d2 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:30:57 +0200 Subject: [PATCH 10/20] =?UTF-8?q?UX:=20Offline-Pfote=20=C3=BCber=20FAB=20+?= =?UTF-8?q?=20nur=20in=20Welten=20sichtbar,=20SW=20by-v1080?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Position: bottom-right über dem #worlds-fab (right:20px, bottom- Berechnung folgt FAB + 12px Abstand). Gleiche horizontale Achse wie FAB → ergibt eine 'Pfoten-Säule' (Indikator oben, FAB unten) - Sichtbarkeit per CSS-Sibling-Selektor: #worlds-overlay.worlds-visible ~ #offline-indicator { display:flex } → Indikator nur sichtbar wenn Welten aktiv sind. Auf Detail-Seiten (Tagebuch, Karte, Admin etc.) bleibt er aus. - z-index 61 (eine Stufe über dem FAB, unter Modals) --- backend/main.py | 2 +- backend/static/css/components.css | 14 +++++++++----- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 5 files changed, 20 insertions(+), 16 deletions(-) diff --git a/backend/main.py b/backend/main.py index 95ed96c..97980e2 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 = "1079" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1080" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 3127ac3..df695bc 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8867,13 +8867,15 @@ svg.empty-state-icon { } /* ============================================================ - Offline-Bereitschafts-Indikator — schwebend oben rechts + Offline-Bereitschafts-Indikator — schwebend über dem Welten-FAB + Sichtbar NUR wenn Welten aktiv sind (Sibling-Selektor) 5 Pfade — Score 0 (grau) bis 5 (grün, gefüllt) ============================================================ */ #offline-indicator { + display: none; /* Default: aus */ position: fixed; - top: calc(env(safe-area-inset-top, 0px) + 8px); - right: 12px; + right: 20px; /* gleicher right wie #worlds-fab */ + bottom: calc(env(safe-area-inset-bottom, 16px) + 16px + 54px + 12px); /* FAB-Bottom + FAB-Höhe + 12px */ width: 40px; height: 40px; border-radius: 50%; @@ -8882,14 +8884,16 @@ svg.empty-state-icon { -webkit-backdrop-filter: blur(6px); border: 1px solid rgba(0,0,0,0.08); box-shadow: 0 2px 8px rgba(0,0,0,0.12); - display: flex; align-items: center; justify-content: center; padding: 0; cursor: pointer; - z-index: 9000; /* unter Modals (~9999), über allem anderen */ + z-index: 61; /* knapp über dem FAB (60), unter Modals */ transition: transform 0.12s, box-shadow 0.12s; } +/* Welten aktiv → Indikator sichtbar */ +#worlds-overlay.worlds-visible ~ #offline-indicator { display: flex; } + [data-theme="dark"] #offline-indicator { background: rgba(31,41,55,0.85); border-color: rgba(255,255,255,0.08); diff --git a/backend/static/index.html b/backend/static/index.html index dd16b3c..eb8d064 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -630,11 +630,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 87aec8c..76397eb 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 = '1079'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1080'; // ← 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/sw.js b/backend/static/sw.js index 711fce0..c5bfe25 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 = '1079'; +const VER = '1080'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From eb0f460304c030414ea746e206ae953436e44074 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:36:27 +0200 Subject: [PATCH 11/20] Fix: Offline-Pfote per JS-Klasse sichtbar (Fallback zum CSS-Sibling), SW by-v1081 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der reine CSS-Sibling-Selektor klappte nicht zuverlässig (vermutlich SW-Cache-Mismatch oder DOM-Reihenfolge im aktuellen Zustand des Users). Lösung: MutationObserver in offline-indicator.js beobachtet class/style auf #worlds-overlay und togglet .visible auf #offline-indicator. CSS akzeptiert jetzt beide Wege: #worlds-overlay.worlds-visible ~ #offline-indicator, #offline-indicator.visible { display: flex; } So bleibt das Layout funktional auch wenn CSS-Compositing oder Cache-Versatz mal nicht greift. console.warn wenn das Element nicht im DOM ist (z.B. wenn alte index.html aus SW-Cache). --- backend/main.py | 2 +- backend/static/css/components.css | 5 +++-- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 20 +++++++++++++++++++- backend/static/sw.js | 2 +- 6 files changed, 33 insertions(+), 14 deletions(-) diff --git a/backend/main.py b/backend/main.py index 97980e2..f2aa6fd 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 = "1080" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1081" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index df695bc..ee3a382 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8891,8 +8891,9 @@ svg.empty-state-icon { z-index: 61; /* knapp über dem FAB (60), unter Modals */ transition: transform 0.12s, box-shadow 0.12s; } -/* Welten aktiv → Indikator sichtbar */ -#worlds-overlay.worlds-visible ~ #offline-indicator { display: flex; } +/* Welten aktiv → Indikator sichtbar (CSS-Sibling + JS-Klasse als Fallback) */ +#worlds-overlay.worlds-visible ~ #offline-indicator, +#offline-indicator.visible { display: flex; } [data-theme="dark"] #offline-indicator { background: rgba(31,41,55,0.85); diff --git a/backend/static/index.html b/backend/static/index.html index eb8d064..017d122 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -630,11 +630,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 76397eb..789a149 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 = '1080'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1081'; // ← 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/offline-indicator.js b/backend/static/js/offline-indicator.js index 36d90c4..802fd04 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -188,14 +188,32 @@ window.OfflineIndicator = (() => { await Promise.all(tasks); } + // ---------------------------------------------------------- + // Sichtbarkeit an Welten-Overlay koppeln + // ---------------------------------------------------------- + function _syncVisibility() { + if (!_btn) return; + const ov = document.getElementById('worlds-overlay'); + const inWorlds = !!ov?.classList.contains('worlds-visible'); + _btn.classList.toggle('visible', inWorlds); + } + // ---------------------------------------------------------- // Init // ---------------------------------------------------------- function init() { _btn = document.getElementById('offline-indicator'); - if (!_btn) return; + if (!_btn) { console.warn('[OfflineIndicator] #offline-indicator nicht im DOM'); return; } _svg = _btn.querySelector('.offline-paw'); _btn.addEventListener('click', _openModal); + + // MutationObserver: Welten-Overlay Klassenänderung → Indikator zeigen/verstecken + const ov = document.getElementById('worlds-overlay'); + if (ov) { + _syncVisibility(); + new MutationObserver(_syncVisibility).observe(ov, { attributes: true, attributeFilter: ['class', 'style'] }); + } + refresh(); // bei SW-Updates und alle 60s neu prüfen diff --git a/backend/static/sw.js b/backend/static/sw.js index c5bfe25..7259411 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 = '1080'; +const VER = '1081'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 53c80b9bf6378af9c08268021661c91fa064d8a0 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:43:56 +0200 Subject: [PATCH 12/20] Fix: Offline-Pfote sichtbar by-default, JS versteckt nur, SW by-v1082 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Logik umgedreht: Default ist 'sichtbar', JS setzt .is-hidden nur wenn explizit nicht in Welten. So robust gegen Sibling-Selektor-Probleme oder CSS-Compositing-Eigenheiten auf iOS PWA. Außerdem: Hintergrund prominenter (rgba 0.95 statt 0.85), echter Border statt Glas-Filter, stärkerer Schatten — bei den vorigen Versuchen war die Pfote vermutlich auch durch Transparenz schwer zu erkennen auf grauem Hintergrund. --- backend/main.py | 2 +- backend/static/css/components.css | 16 ++++++---------- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 8 ++++++-- backend/static/sw.js | 2 +- 6 files changed, 23 insertions(+), 23 deletions(-) diff --git a/backend/main.py b/backend/main.py index f2aa6fd..bf3a3bc 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 = "1081" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1082" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index ee3a382..ee134f5 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8872,28 +8872,24 @@ svg.empty-state-icon { 5 Pfade — Score 0 (grau) bis 5 (grün, gefüllt) ============================================================ */ #offline-indicator { - display: none; /* Default: aus */ + display: flex; /* Default: sichtbar — JS blendet auf Detail-Seiten aus */ position: fixed; right: 20px; /* gleicher right wie #worlds-fab */ bottom: calc(env(safe-area-inset-bottom, 16px) + 16px + 54px + 12px); /* FAB-Bottom + FAB-Höhe + 12px */ width: 40px; height: 40px; border-radius: 50%; - background: rgba(255,255,255,0.85); - backdrop-filter: blur(6px); - -webkit-backdrop-filter: blur(6px); - border: 1px solid rgba(0,0,0,0.08); - box-shadow: 0 2px 8px rgba(0,0,0,0.12); + background: rgba(255,255,255,0.95); + border: 2px solid var(--c-border); + box-shadow: 0 2px 10px rgba(0,0,0,0.18); align-items: center; justify-content: center; padding: 0; cursor: pointer; z-index: 61; /* knapp über dem FAB (60), unter Modals */ - transition: transform 0.12s, box-shadow 0.12s; + transition: transform 0.12s, opacity 0.2s; } -/* Welten aktiv → Indikator sichtbar (CSS-Sibling + JS-Klasse als Fallback) */ -#worlds-overlay.worlds-visible ~ #offline-indicator, -#offline-indicator.visible { display: flex; } +#offline-indicator.is-hidden { display: none; } /* JS-gesteuert: in Detail-Seiten */ [data-theme="dark"] #offline-indicator { background: rgba(31,41,55,0.85); diff --git a/backend/static/index.html b/backend/static/index.html index 017d122..b8426aa 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -630,11 +630,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 789a149..f882e5a 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 = '1081'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1082'; // ← 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/offline-indicator.js b/backend/static/js/offline-indicator.js index 802fd04..ba2971d 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -190,12 +190,16 @@ window.OfflineIndicator = (() => { // ---------------------------------------------------------- // Sichtbarkeit an Welten-Overlay koppeln + // — default sichtbar; nur ausblenden wenn explizit auf Detail-Seite // ---------------------------------------------------------- function _syncVisibility() { if (!_btn) return; const ov = document.getElementById('worlds-overlay'); - const inWorlds = !!ov?.classList.contains('worlds-visible'); - _btn.classList.toggle('visible', inWorlds); + if (!ov) return; // ohne Welten-Overlay sichtbar lassen + const inWorlds = ov.classList.contains('worlds-visible') + || ov.style.display === 'block' + || ov.style.display === ''; + _btn.classList.toggle('is-hidden', !inWorlds); } // ---------------------------------------------------------- diff --git a/backend/static/sw.js b/backend/static/sw.js index 7259411..c6b47d2 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 = '1081'; +const VER = '1082'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From b9fe5b5bc33f917cb73df4016603bdc5ec4a76c8 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 14:57:19 +0200 Subject: [PATCH 13/20] UX: Offline-Score direkt im FAB statt separater Pfote, SW by-v1083 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit User-Feedback: separater Indikator zu viel — die Pfote IM FAB selbst soll je nach Score grün eingefärbt werden. - Separater #offline-indicator Button entfernt (HTML + CSS) - Welten-FAB-Icon: ersetzt durch Inline-SVG mit 5 einzelnen paw-elem-Pfaden (1 Ballen + 4 Zehen) - CSS: Default weiß (wie bisher), .filled wird leuchtendes Grün (#16a34a) — überzeichnet auf orangem FAB klar erkennbar - offline-indicator.js: zeigt jetzt nur noch die FAB-Pfade ein/aus, kein eigenes Element mehr; Klick-Status-Modal als window.OfflineIndicator.openStatus() weiter verfügbar (kann später bei Bedarf an Long-Press oder Menüpunkt gehängt werden) --- backend/main.py | 2 +- backend/static/css/components.css | 45 ++------- backend/static/index.html | 41 ++++----- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 121 +++++++------------------ backend/static/sw.js | 2 +- 6 files changed, 61 insertions(+), 152 deletions(-) diff --git a/backend/main.py b/backend/main.py index bf3a3bc..814445c 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 = "1082" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1083" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index ee134f5..a26655f 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8867,44 +8867,17 @@ svg.empty-state-icon { } /* ============================================================ - Offline-Bereitschafts-Indikator — schwebend über dem Welten-FAB - Sichtbar NUR wenn Welten aktiv sind (Sibling-Selektor) - 5 Pfade — Score 0 (grau) bis 5 (grün, gefüllt) + 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) ============================================================ */ -#offline-indicator { - display: flex; /* Default: sichtbar — JS blendet auf Detail-Seiten aus */ - position: fixed; - right: 20px; /* gleicher right wie #worlds-fab */ - bottom: calc(env(safe-area-inset-bottom, 16px) + 16px + 54px + 12px); /* FAB-Bottom + FAB-Höhe + 12px */ - width: 40px; - height: 40px; - border-radius: 50%; - background: rgba(255,255,255,0.95); - border: 2px solid var(--c-border); - box-shadow: 0 2px 10px rgba(0,0,0,0.18); - align-items: center; - justify-content: center; - padding: 0; - cursor: pointer; - z-index: 61; /* knapp über dem FAB (60), unter Modals */ - transition: transform 0.12s, opacity 0.2s; +#worlds-fab .offline-paw .paw-elem { + color: #fff; + transition: stroke 0.4s ease, fill 0.4s ease; } -#offline-indicator.is-hidden { display: none; } /* JS-gesteuert: in Detail-Seiten */ - -[data-theme="dark"] #offline-indicator { - background: rgba(31,41,55,0.85); - border-color: rgba(255,255,255,0.08); -} -#offline-indicator:active { transform: scale(0.92); } -#offline-indicator .offline-paw { width: 24px; height: 24px; } - -.offline-paw .paw-elem { - color: var(--c-text-muted); - transition: stroke 0.5s ease, fill 0.5s ease; -} -.offline-paw .paw-elem.filled { - color: var(--c-success); - fill: var(--c-success); +#worlds-fab .offline-paw .paw-elem.filled { + color: #16a34a; /* leuchtendes Grün, klar sichtbar auf orange */ + fill: #16a34a; } .offline-status-row { diff --git a/backend/static/index.html b/backend/static/index.html index b8426aa..236ea78 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -602,27 +602,22 @@
- - - @@ -630,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index f882e5a..940be82 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 = '1082'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1083'; // ← 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/offline-indicator.js b/backend/static/js/offline-indicator.js index ba2971d..5f8336b 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -1,7 +1,8 @@ /* ============================================================ - BAN YARO — Offline-Bereitschafts-Indikator - 5-stufige Pfote im Header, zeigt wie viel der App offline - verfügbar ist. Klick → Status-Modal mit Nachlade-Button. + 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 = (() => { @@ -11,9 +12,8 @@ window.OfflineIndicator = (() => { const CACHE_STATIC = `by-v${(window.APP_VER || '0')}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; const CACHE_API = 'ban-yaro-api-v1'; - const TILE_MIN = 50; // Mindest-Tiles für Stufe 4 + const TILE_MIN = 50; - // 5 Offline-Bereitschafts-Checks, in Reihenfolge der Pfoten-Stufen const CHECKS = [ { step: 1, title: 'App-Grundgerüst', detail: 'CSS, Layout und Hauptmodule — die Basis', @@ -27,8 +27,8 @@ window.OfflineIndicator = (() => { if (!c) return false; const must = ['diary.js','map.js','walks.js','erste-hilfe.js']; const keys = await c.keys(); - const have = new Set(keys.map(r => r.url)); - return must.every(name => [...have].some(u => u.includes('/js/pages/' + name))); + const have = keys.map(r => r.url); + return must.every(name => have.some(u => u.includes('/js/pages/' + name))); } }, { step: 3, title: 'Hund- und Tagebuchdaten', @@ -36,8 +36,7 @@ window.OfflineIndicator = (() => { probe: async () => { const c = await caches.open(CACHE_API).catch(() => null); if (!c) return false; - const keys = await c.keys(); - const urls = keys.map(r => r.url); + const urls = (await c.keys()).map(r => r.url); return urls.some(u => /\/api\/dogs\/\d+/.test(u)) && urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u)); } }, @@ -47,12 +46,11 @@ window.OfflineIndicator = (() => { probe: async () => { const c = await caches.open(CACHE_TILES).catch(() => null); if (!c) return false; - const keys = await c.keys(); - return keys.length >= TILE_MIN; + return (await c.keys()).length >= TILE_MIN; } }, { step: 5, title: 'Training & Wissen', - detail: 'Übungen, Wiki-Rassen, Wetter — Welt-Inhalte', + detail: 'Übungen, Wiki-Rassen, Wetter', probe: async () => { const c = await caches.open(CACHE_API).catch(() => null); if (!c) return false; @@ -62,47 +60,34 @@ window.OfflineIndicator = (() => { } }, ]; - let _btn = null; - let _svg = null; - let _lastScore = -1; + let _fab = null; - // ---------------------------------------------------------- - // Score berechnen + Pfote einfärben - // ---------------------------------------------------------- async function refresh() { - if (!_btn) return; - if (!('caches' in window)) { _btn.style.display = 'none'; return; } + _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 }; } })); - const score = results.filter(r => r.ok).length; - _applyScore(score, results); - _lastScore = score; - return { score, results }; - } - - function _applyScore(score, results) { - if (!_svg) return; - _svg.querySelectorAll('.paw-elem').forEach(el => { + _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); }); - _btn.title = `Offline-Bereitschaft: ${score} von 5`; - _btn.setAttribute('aria-label', `Offline-Bereitschaft: ${score} von 5`); + + const score = results.filter(r => r.ok).length; + _fab.setAttribute('data-offline-score', `${score}/5`); + return { score, results }; } - // ---------------------------------------------------------- - // Status-Modal beim Klick - // ---------------------------------------------------------- - async function _openModal() { + // 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 ? '✓' : '○'}
@@ -113,25 +98,21 @@ window.OfflineIndicator = (() => {
`).join(''); - const missing = results.filter(r => !r.ok); - const allOk = missing.length === 0; - UI.modal.open({ title: `🐾 Offline-Bereitschaft ${score}/5`, body: `

- ${allOk - ? 'Deine App ist voll offline-fähig. Du kannst Tagebuch, Karte und Daten auch ohne Internet nutzen.' - : 'Je grüner deine Pfote, desto besser klappt die App ohne Internet. Fehlende Inhalte werden beim nächsten Online-Aufruf automatisch geladen.'} + ${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 - ? `` : ''} + ${missing.length ? `` : ''}
`, @@ -139,8 +120,7 @@ window.OfflineIndicator = (() => { document.getElementById('offline-fill-btn')?.addEventListener('click', async () => { const btn = document.getElementById('offline-fill-btn'); - btn.disabled = true; - btn.textContent = 'Lade …'; + btn.disabled = true; btn.textContent = 'Lade …'; await _fetchMissing(missing); UI.modal.close(); UI.toast.success('Offline-Inhalte aktualisiert.'); @@ -148,14 +128,10 @@ window.OfflineIndicator = (() => { }); } - // ---------------------------------------------------------- - // Fehlende Inhalte aktiv nachladen - // ---------------------------------------------------------- async function _fetchMissing(missing) { const tasks = []; for (const m of missing) { if (m.step === 2) { - // Page-Module fetchen → SW cached sie automatisch ['diary.js','map.js','walks.js','erste-hilfe.js'].forEach(p => tasks.push(fetch(`/js/pages/${p}?v=${window.APP_VER}`).catch(() => {}))); } else if (m.step === 3) { @@ -165,17 +141,13 @@ window.OfflineIndicator = (() => { tasks.push(fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {})); } } else if (m.step === 4) { - // Karten-Tiles: SW per Message anstoßen if (navigator.serviceWorker?.controller) { const pos = await new Promise(res => navigator.geolocation?.getCurrentPosition(p => res(p), () => res(null), { timeout: 4000 })); if (pos) { navigator.serviceWorker.controller.postMessage({ - type: 'CACHE_TILES', - lat: pos.coords.latitude, - lon: pos.coords.longitude, - zoom: 14, - radius: 2, + type: 'CACHE_TILES', lat: pos.coords.latitude, lon: pos.coords.longitude, + zoom: 14, radius: 2, }); } } @@ -188,39 +160,8 @@ window.OfflineIndicator = (() => { await Promise.all(tasks); } - // ---------------------------------------------------------- - // Sichtbarkeit an Welten-Overlay koppeln - // — default sichtbar; nur ausblenden wenn explizit auf Detail-Seite - // ---------------------------------------------------------- - function _syncVisibility() { - if (!_btn) return; - const ov = document.getElementById('worlds-overlay'); - if (!ov) return; // ohne Welten-Overlay sichtbar lassen - const inWorlds = ov.classList.contains('worlds-visible') - || ov.style.display === 'block' - || ov.style.display === ''; - _btn.classList.toggle('is-hidden', !inWorlds); - } - - // ---------------------------------------------------------- - // Init - // ---------------------------------------------------------- function init() { - _btn = document.getElementById('offline-indicator'); - if (!_btn) { console.warn('[OfflineIndicator] #offline-indicator nicht im DOM'); return; } - _svg = _btn.querySelector('.offline-paw'); - _btn.addEventListener('click', _openModal); - - // MutationObserver: Welten-Overlay Klassenänderung → Indikator zeigen/verstecken - const ov = document.getElementById('worlds-overlay'); - if (ov) { - _syncVisibility(); - new MutationObserver(_syncVisibility).observe(ov, { attributes: true, attributeFilter: ['class', 'style'] }); - } - refresh(); - - // bei SW-Updates und alle 60s neu prüfen if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', e => { if (e?.data?.type === 'CACHE_UPDATE') refresh(); @@ -229,7 +170,7 @@ window.OfflineIndicator = (() => { setInterval(refresh, 60_000); } - return { init, refresh }; + return { init, refresh, openStatus }; })(); if (document.readyState !== 'loading') { diff --git a/backend/static/sw.js b/backend/static/sw.js index c6b47d2..8d364e3 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 = '1082'; +const VER = '1083'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 95dccd03befa6fba4fab2a09865cc81f24fa6045 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 15:06:43 +0200 Subject: [PATCH 14/20] Fix: Offline-Score Cache-Detection robust, SW by-v1084 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bug: APP_VER war in app.js nur lokale const, nicht window.APP_VER → offline-indicator.js öffnete Cache 'by-v0-static' statt 'by-v1083-static' → fast alle Stufen blieben grau. Fixes: - app.js: window.APP_VER + window.APP_VERSION explizit setzen - offline-indicator.js: _staticCache() Helper findet den aktuellen Static-Cache per Regex /^by-v\d+-static$/ — versions-unabhängig - Step 1 (App-Shell) prüft jetzt korrekt auf design-system.css UND app.js im Static-Cache, nicht mehr caches.match() mit URL --- backend/main.py | 2 +- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 4 +++- backend/static/js/offline-indicator.js | 25 +++++++++++++++++-------- backend/static/sw.js | 2 +- 5 files changed, 30 insertions(+), 19 deletions(-) diff --git a/backend/main.py b/backend/main.py index 814445c..69fbf78 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 = "1083" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1084" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 236ea78..8d4f29d 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -625,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 940be82..720ee0f 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -3,8 +3,10 @@ Router, State-Management, Navigation, Initialisierung. ============================================================ */ -const APP_VER = '1083'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1084'; // ← 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 index 5f8336b..ea81ebb 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -8,27 +8,36 @@ window.OfflineIndicator = (() => { 'use strict'; - // Cache-Namen — müssen mit sw.js übereinstimmen - const CACHE_STATIC = `by-v${(window.APP_VER || '0')}-static`; + // 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 () => (await caches.match('/css/design-system.css?v=' + window.APP_VER)) != null - || (await caches.match('/css/design-system.css')) != null }, + 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, Karte, Gassi, Erste Hilfe', probe: async () => { - const c = await caches.open(CACHE_STATIC).catch(() => null); + const c = await _staticCache(); if (!c) return false; const must = ['diary.js','map.js','walks.js','erste-hilfe.js']; - const keys = await c.keys(); - const have = keys.map(r => r.url); - return must.every(name => have.some(u => u.includes('/js/pages/' + name))); + const urls = (await c.keys()).map(r => r.url); + return must.every(name => urls.some(u => u.includes('/js/pages/' + name))); } }, { step: 3, title: 'Hund- und Tagebuchdaten', diff --git a/backend/static/sw.js b/backend/static/sw.js index 8d364e3..5dd52bb 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 = '1083'; +const VER = '1084'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 94f02dbe3afd4bbf74ecb974946daba2d4066a9d Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 15:14:07 +0200 Subject: [PATCH 15/20] =?UTF-8?q?UX:=20Mehr=20Offline-Seiten=20precachen?= =?UTF-8?q?=20+=20nur=20Strich=20gr=C3=BCn,=20SW=20by-v1085?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PRIORITY_PAGES erweitert auf 10 Seiten (war 8): zusätzlich health.js, notes.js, expenses.js. admin.js raus — 233 KB, offline irrelevant. Damit funktionieren offline ohne vorherigen Besuch: Tagebuch · Gesundheit · Karte · Gassi · Erste Hilfe · Notizblock Ausgaben · Routen · Giftköder · Vermisst. Offline-Indikator Step 2 prüft jetzt alle 7 vom User genannten Seiten (diary, map, walks, erste-hilfe, notes, expenses, routes) — Pfote wird grün wenn alle im Static-Cache sind. CSS-Färbung umgestellt: nur stroke (Linie) wird grün, kein fill mehr. Pfote behält ihre offene Optik, nur die Outlines wechseln von weiß zu Grün. --- backend/main.py | 2 +- backend/static/css/components.css | 7 +++---- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 6 +++--- backend/static/sw.js | 9 ++++++--- 6 files changed, 22 insertions(+), 20 deletions(-) diff --git a/backend/main.py b/backend/main.py index 69fbf78..376e2c1 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 = "1084" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1085" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index a26655f..45c313a 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8872,12 +8872,11 @@ svg.empty-state-icon { (Default = weiß auf orange, filled = grün auf orange) ============================================================ */ #worlds-fab .offline-paw .paw-elem { - color: #fff; - transition: stroke 0.4s ease, fill 0.4s ease; + color: #fff; /* stroke via currentColor — fill bleibt 'none' aus HTML */ + transition: stroke 0.4s ease; } #worlds-fab .offline-paw .paw-elem.filled { - color: #16a34a; /* leuchtendes Grün, klar sichtbar auf orange */ - fill: #16a34a; + color: #16a34a; /* nur Linie grün, kein Ausfüllen */ } .offline-status-row { diff --git a/backend/static/index.html b/backend/static/index.html index 8d4f29d..30114e6 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -625,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 720ee0f..1375f40 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 = '1084'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1085'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js index ea81ebb..48f2566 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -31,11 +31,11 @@ window.OfflineIndicator = (() => { } }, { step: 2, title: 'Wichtige Seiten', - detail: 'Tagebuch, Karte, Gassi, Erste Hilfe', + detail: 'Tagebuch, Karte, Gassi, Erste Hilfe, Notizblock, Ausgaben, Routen', probe: async () => { const c = await _staticCache(); if (!c) return false; - const must = ['diary.js','map.js','walks.js','erste-hilfe.js']; + const must = ['diary.js','map.js','walks.js','erste-hilfe.js','notes.js','expenses.js','routes.js']; const urls = (await c.keys()).map(r => r.url); return must.every(name => urls.some(u => u.includes('/js/pages/' + name))); } }, @@ -141,7 +141,7 @@ window.OfflineIndicator = (() => { const tasks = []; for (const m of missing) { if (m.step === 2) { - ['diary.js','map.js','walks.js','erste-hilfe.js'].forEach(p => + ['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; diff --git a/backend/static/sw.js b/backend/static/sw.js index 5dd52bb..19811d7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,19 +4,22 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1084'; +const VER = '1085'; 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', From 307b4a54864208a0e82a97ac15b7c0448e30ceae Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 15:25:54 +0200 Subject: [PATCH 16/20] =?UTF-8?q?UX:=20Offline-Pfote=20=E2=80=94=20automat?= =?UTF-8?q?ischer=20Tile-Prefetch=20+=20Step=205=20umgebaut,=20SW=20by-v10?= =?UTF-8?q?86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Step 5 misst jetzt 'Welt-Daten' (Streak + Wetter) statt Wiki/Übungen — die kommen automatisch beim Welten-Aufruf, kein zusätzliches Prefetch nötig (User-Wunsch: Wiki+Übungen NICHT preloaden) - Neuer _prefetchTiles(): beim App-Start werden 49+9 OSM-Tiles (Zoom 14 +13) im 3km-Umkreis automatisch gecacht — aber NUR wenn GPS-Permission schon erteilt ist (kein nerviger Popup beim Start). Damit wird Step 4 nach kurzer Zeit grün. - _fetchMissing für Step 5 lädt jetzt Streak + Wetter (statt Wiki) --- backend/main.py | 2 +- backend/static/index.html | 16 +++---- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 61 +++++++++++++++++++++++--- backend/static/sw.js | 2 +- 5 files changed, 65 insertions(+), 18 deletions(-) diff --git a/backend/main.py b/backend/main.py index 376e2c1..928c345 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 = "1085" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1086" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 30114e6..fed4422 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -625,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 1375f40..ff6bcce 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 = '1085'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1086'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js index 48f2566..a895b24 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -58,17 +58,23 @@ window.OfflineIndicator = (() => { return (await c.keys()).length >= TILE_MIN; } }, - { step: 5, title: 'Training & Wissen', - detail: 'Übungen, Wiki-Rassen, Wetter', + { step: 5, title: 'Welt-Daten', + detail: 'Streak, Wetter, Hundepass — kommt beim Welten-Aufruf', 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/training/exercises')) - && urls.some(u => u.includes('/api/wiki/rassen')); + return urls.some(u => u.includes('/api/streak/')) + && urls.some(u => u.includes('/api/weather')); } }, ]; + // 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() { @@ -161,19 +167,60 @@ window.OfflineIndicator = (() => { } } } else if (m.step === 5) { - tasks.push(fetch('/api/training/exercises').catch(() => {})); - tasks.push(fetch('/api/wiki/rassen?limit=50').catch(() => {})); + // Welt-Daten: Streak braucht Hund-ID, Wetter braucht GPS tasks.push(fetch('/api/weather').catch(() => {})); + const dogId = window._appState?.activeDog?.id; + if (dogId) tasks.push(fetch(`/api/streak/${dogId}`).catch(() => {})); } } 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 {} + } + function init() { refresh(); + _prefetchTiles(); // im Hintergrund if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', e => { - if (e?.data?.type === 'CACHE_UPDATE') refresh(); + if (e?.data?.type === 'CACHE_UPDATE') refresh(); + if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh(); }); } setInterval(refresh, 60_000); diff --git a/backend/static/sw.js b/backend/static/sw.js index 19811d7..59c9a12 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 = '1085'; +const VER = '1086'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 87462cb2fe83468a810acd07408976faffbf120c Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 15:34:42 +0200 Subject: [PATCH 17/20] UX: Offline-Pfote misst echte Offline-Bereitschaft, SW by-v1087 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps so umverteilt dass sie genau die Datentypen abdecken die der User offline braucht — auch wenn er die Seiten nie geöffnet hat: 1 App-Grundgerüst CSS + Core-JS 2 Wichtige Seiten alle 10 Page-Module (precached via SW) 3 Hund-Daten Profil + Tagebuch + Gesundheit 4 Weitere Listen Ausgaben + Routen + Notizen 5 Karten-Kacheln OSM-Tiles im Umkreis Automatischer Prefetch im _prefetchData() beim App-Start: - /api/expenses · /api/routes · /api/notes (Step 4) - /api/dogs/{id}/health · /api/dogs/{id}/diary (Step 3) - Tiles via _prefetchTiles wenn GPS-Permission da (Step 5) Wiki, Übungen, Streak, Wetter werden NICHT mehr vorgeladen — kommen beim normalen Welten-Besuch ins Cache, sind aber nicht Pflicht für 'offline-bereit'. setTimeout-Retry nach 3s: aktiver Hund ist beim ersten Init oft noch nicht in _appState, danach kommt der Health/Diary-Prefetch. --- backend/main.py | 2 +- backend/static/index.html | 16 +++--- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 74 +++++++++++++++----------- backend/static/sw.js | 2 +- 5 files changed, 54 insertions(+), 42 deletions(-) diff --git a/backend/main.py b/backend/main.py index 928c345..99d431c 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 = "1086" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1087" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index fed4422..7abc609 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -625,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index ff6bcce..242695f 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 = '1086'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1087'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js index a895b24..770367f 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -40,33 +40,35 @@ window.OfflineIndicator = (() => { return must.every(name => urls.some(u => u.includes('/js/pages/' + name))); } }, - { step: 3, title: 'Hund- und Tagebuchdaten', - detail: 'Letzte Einträge und Hund-Profil', + { 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); - return urls.some(u => /\/api\/dogs\/\d+/.test(u)) - && urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u)); + return urls.some(u => /\/api\/dogs\/\d+(\?|$)/.test(u)) + && urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u)) + && urls.some(u => /\/api\/dogs\/\d+\/health/.test(u)); } }, - { step: 4, title: 'Karten-Kacheln', - detail: `Mindestens ${TILE_MIN} Tiles im Umkreis`, + { 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; } }, - - { step: 5, title: 'Welt-Daten', - detail: 'Streak, Wetter, Hundepass — kommt beim Welten-Aufruf', - 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/streak/')) - && urls.some(u => u.includes('/api/weather')); - } }, ]; // Tile-Prefetch-Konfiguration @@ -154,23 +156,14 @@ window.OfflineIndicator = (() => { 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) { - if (navigator.serviceWorker?.controller) { - const pos = await new Promise(res => - navigator.geolocation?.getCurrentPosition(p => res(p), () => res(null), { timeout: 4000 })); - if (pos) { - navigator.serviceWorker.controller.postMessage({ - type: 'CACHE_TILES', lat: pos.coords.latitude, lon: pos.coords.longitude, - zoom: 14, radius: 2, - }); - } - } + tasks.push(fetch('/api/expenses').catch(() => {})); + tasks.push(fetch('/api/routes').catch(() => {})); + tasks.push(fetch('/api/notes').catch(() => {})); } else if (m.step === 5) { - // Welt-Daten: Streak braucht Hund-ID, Wetter braucht GPS - tasks.push(fetch('/api/weather').catch(() => {})); - const dogId = window._appState?.activeDog?.id; - if (dogId) tasks.push(fetch(`/api/streak/${dogId}`).catch(() => {})); + await _prefetchTiles(); } } await Promise.all(tasks); @@ -214,9 +207,28 @@ window.OfflineIndicator = (() => { } catch {} } + // Daten-Prefetch beim App-Start: Listen die offline brauchbar sein müssen, + // auch wenn der User die Seiten noch nie geöffnet hat + async function _prefetchData() { + fetch('/api/expenses').catch(() => {}); + fetch('/api/routes').catch(() => {}); + fetch('/api/notes').catch(() => {}); + // Hund-spezifische Daten nur wenn aktiver Hund bekannt + const dogId = window._appState?.activeDog?.id; + if (dogId) { + fetch(`/api/dogs/${dogId}/health`).catch(() => {}); + fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {}); + } + } + function init() { refresh(); - _prefetchTiles(); // im Hintergrund + _prefetchTiles(); // Karten-Tiles (nur wenn GPS schon erlaubt) + _prefetchData(); // Listen-Daten (Expenses, Routes, Notes, Health) + + // Wenn der aktive Hund erst nach init() gesetzt wird → nochmal triggern + setTimeout(() => { _prefetchData(); refresh(); }, 3000); + if (navigator.serviceWorker) { navigator.serviceWorker.addEventListener('message', e => { if (e?.data?.type === 'CACHE_UPDATE') refresh(); diff --git a/backend/static/sw.js b/backend/static/sw.js index 59c9a12..16ae848 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 = '1086'; +const VER = '1087'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From 03725d66820d6c7049f25af9e5f8e213e8489d11 Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 15:52:20 +0200 Subject: [PATCH 18/20] =?UTF-8?q?Fix:=20Offline-Pfote=20=E2=80=94=20Step?= =?UTF-8?q?=202+3=20tolerant,=20mehr=20Prefetch,=20SW=20by-v1088?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Steps wurden nicht grün weil Probes zu strikt waren (alle 7 Module bzw. 3 URL-Patterns erforderlich) — Cache-Inhalt zum Refresh- Zeitpunkt oft unvollständig. - Step 2 toleriert 1 fehlendes Page-Modul (have >= want-1) - Step 3 verlangt nur noch Profil ODER welcome-dashboard PLUS Diary ODER Health (nicht beides) - Neuer _prefetchPages() lädt alle 10 Page-Module proaktiv beim App-Start — unabhängig von SW-Install-Status - _prefetchData() wird jetzt mehrmals retried (2s, 5s, 10s, 20s), damit hund-spezifische Daten geholt werden sobald _appState.activeDog gesetzt ist --- backend/main.py | 2 +- backend/static/index.html | 16 +++++----- backend/static/js/app.js | 2 +- backend/static/js/offline-indicator.js | 41 ++++++++++++++++++-------- backend/static/sw.js | 2 +- 5 files changed, 39 insertions(+), 24 deletions(-) diff --git a/backend/main.py b/backend/main.py index 99d431c..75b1945 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 = "1087" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1088" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 7abc609..71c53fa 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -625,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 242695f..1c686c8 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 = '1087'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1088'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/offline-indicator.js b/backend/static/js/offline-indicator.js index 770367f..20951b3 100644 --- a/backend/static/js/offline-indicator.js +++ b/backend/static/js/offline-indicator.js @@ -31,13 +31,15 @@ window.OfflineIndicator = (() => { } }, { step: 2, title: 'Wichtige Seiten', - detail: 'Tagebuch, Karte, Gassi, Erste Hilfe, Notizblock, Ausgaben, Routen', + detail: 'Tagebuch, Gesundheit, Karte, Gassi, Erste Hilfe, Notizen, Ausgaben, Routen', probe: async () => { const c = await _staticCache(); if (!c) return false; - const must = ['diary.js','map.js','walks.js','erste-hilfe.js','notes.js','expenses.js','routes.js']; + 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); - return must.every(name => urls.some(u => u.includes('/js/pages/' + name))); + 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', @@ -46,9 +48,12 @@ window.OfflineIndicator = (() => { 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 => /\/api\/dogs\/\d+(\?|$)/.test(u)) - && urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u)) - && urls.some(u => /\/api\/dogs\/\d+\/health/.test(u)); + 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', @@ -207,15 +212,21 @@ window.OfflineIndicator = (() => { } catch {} } - // Daten-Prefetch beim App-Start: Listen die offline brauchbar sein müssen, + // 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 - async function _prefetchData() { + function _prefetchData() { fetch('/api/expenses').catch(() => {}); fetch('/api/routes').catch(() => {}); fetch('/api/notes').catch(() => {}); - // Hund-spezifische Daten nur wenn aktiver Hund bekannt 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(() => {}); } @@ -223,11 +234,15 @@ window.OfflineIndicator = (() => { function init() { refresh(); - _prefetchTiles(); // Karten-Tiles (nur wenn GPS schon erlaubt) - _prefetchData(); // Listen-Daten (Expenses, Routes, Notes, Health) + _prefetchPages(); + _prefetchTiles(); + _prefetchData(); - // Wenn der aktive Hund erst nach init() gesetzt wird → nochmal triggern - setTimeout(() => { _prefetchData(); refresh(); }, 3000); + // 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 => { diff --git a/backend/static/sw.js b/backend/static/sw.js index 16ae848..e52aa25 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 = '1087'; +const VER = '1088'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten From d47fb61abf93f3423f0dec7ba3c9e93fffa6778f Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 15:56:03 +0200 Subject: [PATCH 19/20] Fix: /api/notes ins SW Cacheable-Liste aufnehmen, SW by-v1089 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 4 der Offline-Pfote (Weitere Listen) blieb weiß weil /api/notes nicht in _CACHEABLE_GET stand — Notes-Requests wurden vom SW gar nicht gecacht, egal wie oft sie geladen wurden. Regex /^\/api\/notes/ ergänzt — jetzt cached der SW Notes-GETs mit Stale-While-Revalidate (default 5min TTL). --- backend/main.py | 2 +- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/sw.js | 3 ++- 4 files changed, 12 insertions(+), 11 deletions(-) diff --git a/backend/main.py b/backend/main.py index 75b1945..f673f25 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 = "1088" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1089" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/index.html b/backend/static/index.html index 71c53fa..d3fad77 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -625,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 1c686c8..d52f442 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 = '1088'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1089'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/sw.js b/backend/static/sw.js index e52aa25..d651ae7 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 = '1088'; +const VER = '1089'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten @@ -175,6 +175,7 @@ const _CACHEABLE_GET = [ /^\/api\/walks/, /^\/api\/lost/, /^\/api\/expenses/, + /^\/api\/notes/, // Drei Welten — offline-fähig /^\/api\/streak\/\d+/, /^\/api\/forum\/threads/, From 2876469e915d4dce3b77af6bccb7d6705b29506a Mon Sep 17 00:00:00 2001 From: rene Date: Tue, 26 May 2026 16:00:25 +0200 Subject: [PATCH 20/20] =?UTF-8?q?UX:=20Offline-Pfote=20in=20Ban-Yaro-Braun?= =?UTF-8?q?=20statt=20Gr=C3=BCn,=20SW=20by-v1090?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Filled-Farbe der Pfoten-Linien von #16a34a (grün) auf #5C3517 (dunkles Ban Yaro Braun) — passt zum Brand statt fremder Signalfarbe, klar erkennbar auf orangem FAB. --- backend/main.py | 2 +- backend/static/css/components.css | 2 +- backend/static/index.html | 16 ++++++++-------- backend/static/js/app.js | 2 +- backend/static/sw.js | 2 +- 5 files changed, 12 insertions(+), 12 deletions(-) diff --git a/backend/main.py b/backend/main.py index f673f25..8378aee 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 = "1089" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "1090" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): diff --git a/backend/static/css/components.css b/backend/static/css/components.css index 45c313a..4ec2cef 100644 --- a/backend/static/css/components.css +++ b/backend/static/css/components.css @@ -8876,7 +8876,7 @@ svg.empty-state-icon { transition: stroke 0.4s ease; } #worlds-fab .offline-paw .paw-elem.filled { - color: #16a34a; /* nur Linie grün, kein Ausfüllen */ + color: #5C3517; /* dunkles Ban Yaro Braun — klar auf orangem FAB */ } .offline-status-row { diff --git a/backend/static/index.html b/backend/static/index.html index d3fad77..37c7fee 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + + @@ -625,11 +625,11 @@ - - - - - + + + + + diff --git a/backend/static/js/app.js b/backend/static/js/app.js index d52f442..16cdbbe 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 = '1089'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1090'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/sw.js b/backend/static/sw.js index d651ae7..6fce525 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 = '1089'; +const VER = '1090'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten