Compare commits

..

20 commits

Author SHA1 Message Date
2876469e91 UX: Offline-Pfote in Ban-Yaro-Braun statt Grün, SW by-v1090
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.
2026-05-26 16:00:25 +02:00
d47fb61abf Fix: /api/notes ins SW Cacheable-Liste aufnehmen, SW by-v1089
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).
2026-05-26 15:56:03 +02:00
03725d6682 Fix: Offline-Pfote — Step 2+3 tolerant, mehr Prefetch, SW by-v1088
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
2026-05-26 15:52:20 +02:00
87462cb2fe UX: Offline-Pfote misst echte Offline-Bereitschaft, SW by-v1087
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.
2026-05-26 15:34:42 +02:00
307b4a5486 UX: Offline-Pfote — automatischer Tile-Prefetch + Step 5 umgebaut, SW by-v1086
- 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)
2026-05-26 15:25:54 +02:00
94f02dbe3a UX: Mehr Offline-Seiten precachen + nur Strich grün, SW by-v1085
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.
2026-05-26 15:14:07 +02:00
95dccd03be Fix: Offline-Score Cache-Detection robust, SW by-v1084
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
2026-05-26 15:06:43 +02:00
b9fe5b5bc3 UX: Offline-Score direkt im FAB statt separater Pfote, SW by-v1083
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: <use phosphor.svg#paw-print> 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)
2026-05-26 14:57:19 +02:00
53c80b9bf6 Fix: Offline-Pfote sichtbar by-default, JS versteckt nur, SW by-v1082
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.
2026-05-26 14:43:56 +02:00
eb0f460304 Fix: Offline-Pfote per JS-Klasse sichtbar (Fallback zum CSS-Sibling), SW by-v1081
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).
2026-05-26 14:36:27 +02:00
521b7b6bee UX: Offline-Pfote über FAB + nur in Welten sichtbar, SW by-v1080
- 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)
2026-05-26 14:30:57 +02:00
06b91dc54b Fix: Offline-Pfote als schwebendes Element (Welten verstecken Header), SW by-v1079
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())
2026-05-26 14:24:45 +02:00
776641fa65 Fix: Offline-Indicator Cache-Namen + Step-5-Check, SW by-v1078
- 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
2026-05-26 14:18:47 +02:00
8097d21605 Feature: Offline-Bereitschafts-Indikator (Pfote im Header), SW by-v1077
- 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
2026-05-26 14:16:57 +02:00
280213c11d UX: Rechnungs-Modal Footer für Mobile, SW by-v1076
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)
2026-05-26 14:03:09 +02:00
c4a82e96fd UX: Rechnungs-Edit-Modal status-bewusst + Storno-Button, SW by-v1075
- _openNeueRechnungModal akzeptiert jetzt status+invoice_number; je
  nach Status anderes Verhalten:
  • draft   → bearbeitbar, Stornieren + Abbrechen + Speichern
  • sent    → readonly Banner, Stornieren + Schließen
  • paid    → readonly Banner, Stornieren + Schließen
  • neu     → Abbrechen + Erstellen (kein Storno-Slot)
- Footer-Layout: Stornieren links, Abbrechen/Speichern rechts
  (justify-content:space-between → symmetrisch)
- Stornieren öffnet den existierenden _openStornoModal; das Edit-
  Modal wird durch UI.modal.open() automatisch geschlossen
- Submit-Handler ignoriert locked-Status (Schutz auch wenn Button
  irgendwie sichtbar wäre)
- Upgrades-Tab + Rechnungen-Tab geben jetzt inv.status+invoice_number
  beim Öffnen mit; Reload-Callback aus Upgrades-Tab rendert den Tab
  neu, damit nach Stornierung der gelbe Button zurück auf orange geht
2026-05-26 13:59:40 +02:00
5886e1b269 UX: Upgrades-Tab — Button zeigt vorhandene Rechnung an, SW by-v1074
- 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.
2026-05-26 13:50:03 +02:00
e5abdcab62 Fix: Tagebuch Foto-Löschen — null-crash + 404-Cleanup, SW by-v1073
- '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.
2026-05-26 13:38:11 +02:00
c03884cb81 Perf: 9 Performance-Fixes — SW by-v1072
Backend:
- DB: 3 neue Indizes (forum_posts thread+user, routes user) — Forum/Routen-Queries
- Caching: cache.py (TTL-Cache ohne neue Dependency) für 5 statische Listen
  (training_exercises, pflege_tipps, wiki_stats, wiki_gruppen, help_articles)
- diary.py + breeder_photos.py: Bildverarbeitung (ffmpeg/PIL/EXIF) per
  run_in_executor → blockiert Event-Loop nicht mehr
- scheduler.py: 11 kollidierende Jobs auf 5-Min-Intervalle gestaggert, coalesce=True
- social.py: ORDER BY RANDOM() ohne LIMIT in 2 Stellen gefixt
- alerts.py: Haversine-Loop bekommt SQL-Bounding-Box-Vorfilter

Frontend:
- sw.js: Tile-Cache mit LRU-Eviction (max 500 Einträge)
- admin.js: Event-Listener-Leak — Tab-Klicks per Delegation statt N Listener
- api.js: compressImage() Helper — Client-seitiges Resize auf max 2000px
  (HEIC/Videos/<500KB unverändert), integriert in 8 Upload-Stellen
  (diary, dog-profile×2, walks, poison, lost, health×2)

Bump APP_VER 1071 → 1072 (sw.js, app.js, main.py, index.html)
2026-05-26 06:30:36 +02:00
3abf974d29 Feature: Parallele Bild-Uploads, Heartbeat last_seen, Admin zuletzt aktiv, SW by-v1071
- 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
2026-05-25 20:26:58 +02:00
27 changed files with 930 additions and 153 deletions

103
backend/cache.py Normal file
View file

@ -0,0 +1,103 @@
"""BAN YARO — In-Memory TTL-Cache für statische DB-Daten.
Hintergrund:
Routes wie /api/training/exercises, /api/help, /api/wiki/stats laden bei
jedem Request statische Daten aus der DB. Das ist verschwendete Energie.
Diese Daten ändern sich nur durch Admin-Aktionen.
Verwendung:
from cache import ttl_cache
@ttl_cache(ttl=3600)
def my_func(arg1, arg2):
...
API:
@ttl_cache(ttl=3600) Decorator cached pro Argumenten-Signatur
my_func.cache_clear() Komplett leeren (z.B. nach Admin-Update)
Hinweis:
Diese Implementierung ist absichtlich klein und ohne externe Dependency
(kein cachetools nötig). Thread-safe via Lock. Reicht für Read-Only-
Listen, die sich selten ändern. Niemals für user-spezifische Daten
verwenden!
"""
from __future__ import annotations
import functools
import threading
import time
from typing import Any, Callable
def ttl_cache(ttl: int = 3600, maxsize: int = 128) -> Callable:
"""Decorator: cached Rückgabewert pro Argumenten-Signatur für `ttl` Sek.
- ttl: Time-to-live in Sekunden (Default: 1 Stunde)
- maxsize: max. Anzahl Einträge im Cache (FIFO-Eviction bei Überlauf)
Die dekorierte Funktion bekommt zusätzlich:
.cache_clear() leert den gesamten Cache
.cache_info() {hits, misses, size, ttl, maxsize}
"""
def decorator(func: Callable) -> Callable:
store: dict[tuple, tuple[float, Any]] = {}
lock = threading.Lock()
stats = {"hits": 0, "misses": 0}
def _make_key(args: tuple, kwargs: dict) -> tuple:
# kwargs als sortiertes Tuple in den Key packen
if kwargs:
return args + tuple(sorted(kwargs.items()))
return args
@functools.wraps(func)
def wrapper(*args, **kwargs):
key = _make_key(args, kwargs)
now = time.monotonic()
with lock:
cached = store.get(key)
if cached is not None:
expires_at, value = cached
if expires_at > now:
stats["hits"] += 1
return value
# abgelaufen → raus
del store[key]
stats["misses"] += 1
# Außerhalb des Locks ausführen (kann DB-Calls machen)
value = func(*args, **kwargs)
with lock:
# FIFO-Eviction, wenn maxsize überschritten
if len(store) >= maxsize:
try:
oldest_key = next(iter(store))
del store[oldest_key]
except StopIteration:
pass
store[key] = (now + ttl, value)
return value
def cache_clear() -> None:
with lock:
store.clear()
stats["hits"] = 0
stats["misses"] = 0
def cache_info() -> dict:
with lock:
return {
"hits": stats["hits"],
"misses": stats["misses"],
"size": len(store),
"ttl": ttl,
"maxsize": maxsize,
}
wrapper.cache_clear = cache_clear # type: ignore[attr-defined]
wrapper.cache_info = cache_info # type: ignore[attr-defined]
return wrapper
return decorator

View file

@ -180,6 +180,8 @@ def init_db():
anz_bewertungen INTEGER DEFAULT 0, anz_bewertungen INTEGER DEFAULT 0,
created_at TEXT NOT NULL DEFAULT (datetime('now')) 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 ( CREATE TABLE IF NOT EXISTS route_walks (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, 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')), created_at TEXT NOT NULL DEFAULT (datetime('now')),
edited_at TEXT 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) -- PUSH SUBSCRIPTIONS (alternativ zu users.push_sub für mehrere Geräte)
CREATE TABLE IF NOT EXISTS push_subscriptions ( CREATE TABLE IF NOT EXISTS push_subscriptions (

View file

@ -410,7 +410,7 @@ async def serve_media(path: str, request: _Request):
raise _HE(404, "Nicht gefunden.") raise _HE(404, "Nicht gefunden.")
return _media_response(filepath) return _media_response(filepath)
APP_VER = "1070" # 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") @app.get("/.well-known/assetlinks.json")
async def assetlinks(): async def assetlinks():

View file

@ -359,7 +359,7 @@ async def list_users(
SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium, SELECT u.id, u.name, {_email_col}, u.rolle, u.is_premium,
u.is_moderator, u.is_banned, u.ban_reason, u.is_moderator, u.is_banned, u.ban_reason,
u.is_founder, u.is_partner, u.founder_number, 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 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, (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, ROUND(COALESCE((SELECT SUM(r.distanz_km) FROM routes r WHERE r.user_id=u.id), 0), 1) AS total_km,
@ -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, SELECT r.id, r.user_id, r.tier, r.message, r.created_at, r.fulfilled_at,
u.name, u.email, u.billing_address, u.name, u.email, u.billing_address,
u.is_founder, u.is_founder_pending, u.referred_by, 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 FROM upgrade_requests r
JOIN users u ON u.id = r.user_id JOIN users u ON u.id = r.user_id
ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC ORDER BY r.fulfilled_at IS NOT NULL, r.created_at DESC

View file

@ -9,6 +9,7 @@ from auth import get_current_user_optional as get_optional_user
router = APIRouter() router = APIRouter()
_RADIUS_M = 20_000 # 20 km _RADIUS_M = 20_000 # 20 km
_RADIUS_KM = _RADIUS_M / 1000.0
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: 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)) 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("") @router.get("")
async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user)): async def nearby_alerts(lat: float, lon: float, user=Depends(get_optional_user)):
now = datetime.utcnow().isoformat() now = datetime.utcnow().isoformat()
lat_min, lat_max, lon_min, lon_max = _bbox(lat, lon, _RADIUS_KM)
with db() as conn: 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( 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() ).fetchall()
lost = conn.execute( 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() ).fetchall()
# Letzten Standort des Users für geo-basierte Push-Filter speichern # Letzten Standort des Users für geo-basierte Push-Filter speichern
if user: if user:

View file

@ -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"],) "UPDATE users SET needs_dog_selection=0 WHERE id=?", (user["id"],)
) )
return {"ok": True} 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}

View file

@ -3,7 +3,7 @@ from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
from fastapi.responses import FileResponse from fastapi.responses import FileResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional from typing import Optional
import os, logging import os, logging, asyncio
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
from media_utils import validate_upload, generate_preview from media_utils import validate_upload, generate_preview
@ -112,27 +112,37 @@ async def upload_photo(
file_uuid = str(uuid.uuid4()) file_uuid = str(uuid.uuid4())
file_path = os.path.join(save_dir, f"{file_uuid}.webp") 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 # 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 thumb_path = None
if thumb_bytes: if thumb_bytes:
thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp") thumb_path = os.path.join(save_dir, f"{file_uuid}_thumb.webp")
with open(thumb_path, "wb") as f: await loop.run_in_executor(None, lambda: _write_bytes(thumb_path, thumb_bytes))
f.write(thumb_bytes)
# Originalbild konvertieren und speichern # Originalbild konvertieren und speichern (Pillow direkt — WebP-Qualität 85)
# generate_preview liefert WebP, für das Original nehmen wir Pillow direkt def _save_original():
try: try:
import io import io
from PIL import Image, ImageOps from PIL import Image, ImageOps
img = Image.open(io.BytesIO(raw_data)) img = Image.open(io.BytesIO(raw_data))
img = ImageOps.exif_transpose(img) img = ImageOps.exif_transpose(img)
img = img.convert("RGB") img = img.convert("RGB")
img.save(file_path, format="WEBP", quality=85) img.save(file_path, format="WEBP", quality=85)
except Exception: except Exception:
# Fallback: Rohdaten speichern # Fallback: Rohdaten speichern
with open(file_path, "wb") as f: _write_bytes(file_path, raw_data)
f.write(raw_data)
await loop.run_in_executor(None, _save_original)
# Relative Pfade für DB (relativ zu MEDIA_DIR) # Relative Pfade für DB (relativ zu MEDIA_DIR)
rel_file = os.path.relpath(file_path, MEDIA_DIR) rel_file = os.path.relpath(file_path, MEDIA_DIR)

View file

@ -1,6 +1,6 @@
"""BAN YARO — Tagebuch Routes""" """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 fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from pydantic import BaseModel from pydantic import BaseModel
from typing import Optional 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 "") validate_upload(raw_data, file.filename or "")
except ValueError as e: except ValueError as e:
raise HTTPException(415, str(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: if not ext:
ext = ".jpg" ext = ".jpg"
filename = f"diary_{entry_id}_{uuid.uuid4().hex[:8]}{ext}" 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 "") media_type = _guess_media_type(ct, file.filename or "")
os.makedirs(os.path.dirname(path), exist_ok=True) os.makedirs(os.path.dirname(path), exist_ok=True)
with open(path, "wb") as f: def _write_bytes(p: str, data: bytes) -> None:
f.write(raw_data) 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": if media_type == "video":
extract_video_thumb(path) await loop.run_in_executor(None, lambda: extract_video_thumb(path))
elif media_type == "image": 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: if preview_bytes:
preview_path = os.path.splitext(path)[0] + "_preview.webp" preview_path = os.path.splitext(path)[0] + "_preview.webp"
with open(preview_path, "wb") as f: await loop.run_in_executor(None, lambda: _write_bytes(preview_path, preview_bytes))
f.write(preview_bytes)
media_url = f"/media/diary/{filename}" media_url = f"/media/diary/{filename}"
@ -710,8 +720,8 @@ async def upload_media(dog_id: int, entry_id: int,
exif_gps = None exif_gps = None
img_size = None img_size = None
if media_type == "image": if media_type == "image":
exif_gps = extract_gps_from_exif(raw_data) exif_gps = await loop.run_in_executor(None, lambda: extract_gps_from_exif(raw_data))
img_size = get_image_size(raw_data) img_size = await loop.run_in_executor(None, lambda: get_image_size(raw_data))
with db() as conn: with db() as conn:
# sort_order = nächste freie Position # sort_order = nächste freie Position

View file

@ -9,6 +9,20 @@ from database import db
from auth import get_current_user, has_pro_access from auth import get_current_user, has_pro_access
from routes.push import send_push_to_user from routes.push import send_push_to_user
from media_utils import safe_media_path, preview_url_from 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() router = APIRouter()
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") 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"]): elif any(w in beschr for w in ["schneid", "geschoren", "schere", "clipper"]):
fell_pflege_art_filter = "schneiden" fell_pflege_art_filter = "schneiden"
with db() as conn: # Statische Tipps aus Cache (1h TTL) Filterung passiert in-memory
alle_tipps = conn.execute( alle_tipps = _load_all_pflege_tipps()
"SELECT * FROM pflege_tipps ORDER BY kategorie, titel"
).fetchall()
# Relevante Tipps: kein Fell-Filter oder passend # Relevante Tipps: kein Fell-Filter oder passend
from datetime import date from datetime import date

View file

@ -5,10 +5,28 @@ from pydantic import BaseModel
from typing import Optional from typing import Optional
from database import db from database import db
from auth import get_current_user_optional, require_admin from auth import get_current_user_optional, require_admin
from cache import ttl_cache
router = APIRouter() 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 # Schemas
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -39,22 +57,17 @@ def get_help(
is_admin = user and user.get("rolle") == "admin" is_admin = user and user.get("rolle") == "admin"
show_all = all == 1 and is_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( rows = conn.execute(
"SELECT id, kategorie, frage, antwort, sort_order, aktiv " "SELECT id, kategorie, frage, antwort, sort_order, aktiv "
"FROM help_articles " "FROM help_articles "
"ORDER BY kategorie, sort_order, id" "ORDER BY kategorie, sort_order, id"
).fetchall() ).fetchall()
else: return [dict(r) for r in rows]
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] # Ö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 (?, ?, ?, ?, ?)", "VALUES (?, ?, ?, ?, ?)",
(body.kategorie, body.frage, body.antwort, body.sort_order, body.aktiv), (body.kategorie, body.frage, body.antwort, body.sort_order, body.aktiv),
) )
_load_active_help_articles.cache_clear()
return {"ok": True, "id": cur.lastrowid} 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=?", f"UPDATE help_articles SET {set_clause} WHERE id=?",
(*updates.values(), article_id), (*updates.values(), article_id),
) )
_load_active_help_articles.cache_clear()
return {"ok": True} 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)): def delete_article(article_id: int, admin=Depends(require_admin)):
with db() as conn: with db() as conn:
conn.execute("DELETE FROM help_articles WHERE id=?", (article_id,)) conn.execute("DELETE FROM help_articles WHERE id=?", (article_id,))
_load_active_help_articles.cache_clear()
return {"ok": True} return {"ok": True}

View file

@ -1278,21 +1278,26 @@ except Exception:
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.post("/training-tip") @router.post("/training-tip")
async def training_tip(user=Depends(require_social_media)): 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: with db() as conn:
used = {r["exercise_id"] for r in conn.execute( row = conn.execute(
"SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL" """SELECT * FROM training_exercises
).fetchall()} WHERE exercise_id NOT IN (
all_ex = conn.execute( SELECT exercise_id FROM social_content WHERE exercise_id IS NOT NULL
"SELECT * FROM training_exercises ORDER BY RANDOM()" )
).fetchall() 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] if not row:
pool = unused if unused else list(all_ex) # Reset wenn alle durch
if not pool:
raise HTTPException(404, "Keine Übungen gefunden.") raise HTTPException(404, "Keine Übungen gefunden.")
ex = dict(pool[0]) ex = dict(row)
stil = random.choice(_TRAINING_STILE) stil = random.choice(_TRAINING_STILE)
schritte_list = json.loads(ex["schritte"] or "[]") schritte_list = json.loads(ex["schritte"] or "[]")
schritte_text = "\n".join(f" {i+1}. {s}" for i, s in enumerate(schritte_list[:4])) 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,), (breed_id,),
).fetchone() ).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( tipps = conn.execute(
"SELECT * FROM pflege_tipps ORDER BY RANDOM()" "SELECT * FROM pflege_tipps ORDER BY RANDOM() LIMIT 100"
).fetchall() ).fetchall()
# Noch nicht verwendete bevorzugen # Noch nicht verwendete bevorzugen

View file

@ -7,15 +7,16 @@ import datetime
import ki import ki
from database import db from database import db
from auth import get_current_user, require_admin from auth import get_current_user, require_admin
from cache import ttl_cache
router = APIRouter() router = APIRouter()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Alle Übungen aus DB (öffentlich, kein Auth) # Alle Übungen aus DB (öffentlich, kein Auth)
# Statische Daten → 1h TTL-Cache. Wird in update_exercise() invalidiert.
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@router.get("/exercises") @ttl_cache(ttl=3600)
async def get_exercises(): def _load_exercises_by_tab() -> dict:
"""Alle Übungen aus der DB, gruppiert nach Tab-ID."""
import json as _json import json as _json
CAT_TO_TAB = { CAT_TO_TAB = {
'Grundkommando': 'grundkommandos', 'Grundkommando': 'grundkommandos',
@ -33,7 +34,7 @@ async def get_exercises():
dauer, beschreibung, schritte, tipp dauer, beschreibung, schritte, tipp
FROM training_exercises ORDER BY kategorie, name FROM training_exercises ORDER BY kategorie, name
""").fetchall() """).fetchall()
by_tab = {} by_tab: dict = {}
for r in rows: for r in rows:
tab = CAT_TO_TAB.get(r['kategorie'], r['kategorie'].lower().replace(' ', '-')) tab = CAT_TO_TAB.get(r['kategorie'], r['kategorie'].lower().replace(' ', '-'))
by_tab.setdefault(tab, []).append({ by_tab.setdefault(tab, []).append({
@ -50,6 +51,12 @@ async def get_exercises():
}) })
return by_tab 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) # 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} return {"ok": True, "updated": 0}
vals.append(exercise_id) vals.append(exercise_id)
conn.execute(f"UPDATE training_exercises SET {', '.join(fields)} WHERE id=?", vals) 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)} return {"ok": True, "updated": len(fields)}
# ------------------------------------------------------------------ # ------------------------------------------------------------------

View file

@ -10,6 +10,7 @@ from pydantic import BaseModel
from database import db from database import db
from auth import get_current_user, get_current_user_optional from auth import get_current_user, get_current_user_optional
from ratelimit import check as rl_check, block_ip from ratelimit import check as rl_check, block_ip
from cache import ttl_cache
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") 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") @ttl_cache(ttl=3600)
async def get_stats(): def _wiki_stats() -> dict:
with db() as conn: with db() as conn:
row = conn.execute("SELECT COUNT(*) as total FROM wiki_rassen").fetchone() row = conn.execute("SELECT COUNT(*) as total FROM wiki_rassen").fetchone()
total = row["total"] if row else 0 total = row["total"] if row else 0
return {"total_breeds": total, "seeded": total > 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) # GET /api/wiki/rassen — alle Rassen (Übersicht, paginiert)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@ -134,15 +153,13 @@ async def get_rassen(
SELECT COUNT(*) as total FROM wiki_rassen {where} SELECT COUNT(*) as total FROM wiki_rassen {where}
""", args).fetchone() """, args).fetchone()
# Alle Gruppen für Filter-Dropdown # Alle Gruppen für Filter-Dropdown (gecached, 1h TTL)
gruppen_rows = conn.execute( gruppen = _wiki_gruppen()
"SELECT DISTINCT gruppe FROM wiki_rassen WHERE gruppe IS NOT NULL ORDER BY gruppe"
).fetchall()
return { return {
"breeds": [dict(r) for r in rows], "breeds": [dict(r) for r in rows],
"total": count_row["total"] if count_row else 0, "total": count_row["total"] if count_row else 0,
"gruppen": [r["gruppe"] for r in gruppen_rows], "gruppen": gruppen,
} }

View file

@ -24,12 +24,19 @@ _job_log: dict = {}
def start(): 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( _scheduler.add_job(
_job_health_reminders, _job_health_reminders,
CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr CronTrigger(hour=8, minute=0), # täglich 08:00 Uhr
id="health_reminders", id="health_reminders",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
_scheduler.add_job( _scheduler.add_job(
_job_poison_archive, _job_poison_archive,
@ -37,6 +44,7 @@ def start():
id="poison_archive", id="poison_archive",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
_scheduler.add_job( _scheduler.add_job(
_job_weather_alert, _job_weather_alert,
@ -44,6 +52,7 @@ def start():
id="weather_alert", id="weather_alert",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
_scheduler.add_job( _scheduler.add_job(
_job_milestone_check, _job_milestone_check,
@ -51,6 +60,7 @@ def start():
id="milestone_check", id="milestone_check",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
_scheduler.add_job( _scheduler.add_job(
_job_import_events, _job_import_events,
@ -58,6 +68,7 @@ def start():
id="import_events", id="import_events",
replace_existing=True, replace_existing=True,
misfire_grace_time=7200, misfire_grace_time=7200,
coalesce=True,
) )
# Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung # Einmalig beim Start (nach 10s Verzögerung) für sofortige Befüllung
@ -68,29 +79,32 @@ def start():
id="import_events_startup", id="import_events_startup",
replace_existing=True, 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( _scheduler.add_job(
_job_seed_breeds, _job_seed_breeds,
CronTrigger(day=1, hour=3, minute=0), # 1. jedes Monats CronTrigger(day=1, hour=3, minute=0), # 1. jedes Monats
id="seed_breeds", id="seed_breeds",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, 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( _scheduler.add_job(
_job_seed_wikidata_breeds, _job_seed_wikidata_breeds,
CronTrigger(day=1, hour=4, minute=0), # 1. jedes Monats CronTrigger(day=1, hour=4, minute=0), # 1. jedes Monats
id="seed_wikidata", id="seed_wikidata",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, 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( _scheduler.add_job(
_job_weekly_praise, _job_weekly_praise,
CronTrigger(day_of_week='mon', hour=9, minute=0), CronTrigger(day_of_week='mon', hour=9, minute=5),
id="weekly_praise", id="weekly_praise",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# Täglich 06:00 Uhr Status-Report per Mail # Täglich 06:00 Uhr Status-Report per Mail
_scheduler.add_job( _scheduler.add_job(
@ -99,6 +113,7 @@ def start():
id="status_report", id="status_report",
replace_existing=True, replace_existing=True,
misfire_grace_time=1800, misfire_grace_time=1800,
coalesce=True,
) )
# Täglich 12:00 — Moderation-Overdue-Check # Täglich 12:00 — Moderation-Overdue-Check
_scheduler.add_job( _scheduler.add_job(
@ -107,22 +122,25 @@ def start():
id="moderation_overdue", id="moderation_overdue",
replace_existing=True, replace_existing=True,
misfire_grace_time=1800, 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( _scheduler.add_job(
_job_quarterly_report, _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", id="quarterly_report",
replace_existing=True, replace_existing=True,
misfire_grace_time=7200, 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( _scheduler.add_job(
_job_ki_health_report, _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", id="ki_health_report",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# Täglich 06:30 — Wiederkehrende Ausgaben anlegen # Täglich 06:30 — Wiederkehrende Ausgaben anlegen
_scheduler.add_job( _scheduler.add_job(
@ -131,6 +149,7 @@ def start():
id="recurring_expenses", id="recurring_expenses",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# 1. des Monats 00:05 — Hund des Monats Sieger festlegen # 1. des Monats 00:05 — Hund des Monats Sieger festlegen
_scheduler.add_job( _scheduler.add_job(
@ -139,6 +158,7 @@ def start():
id="hdm_winner", id="hdm_winner",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# Täglich 19:00 Uhr — Streak-Erinnerung # Täglich 19:00 Uhr — Streak-Erinnerung
_scheduler.add_job( _scheduler.add_job(
@ -147,22 +167,25 @@ def start():
id="streak_reminder", id="streak_reminder",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, 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( _scheduler.add_job(
_job_recall_check, _job_recall_check,
CronTrigger(hour=8, minute=0), CronTrigger(hour=8, minute=5),
id="recall_check", id="recall_check",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, 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( _scheduler.add_job(
_job_new_foto_challenge, _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", id="new_foto_challenge",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# Täglich 07:00 Uhr — Goldene Gassi-Stunde # Täglich 07:00 Uhr — Goldene Gassi-Stunde
_scheduler.add_job( _scheduler.add_job(
@ -171,6 +194,7 @@ def start():
id="golden_gassi_hour", id="golden_gassi_hour",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren) # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren)
_scheduler.add_job( _scheduler.add_job(
@ -179,6 +203,7 @@ def start():
id="anniversary_reminders", id="anniversary_reminders",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# 1. des Monats 10:00 — Monatlicher Rückblick per Push # 1. des Monats 10:00 — Monatlicher Rückblick per Push
_scheduler.add_job( _scheduler.add_job(
@ -187,13 +212,16 @@ def start():
id="monthly_recap", id="monthly_recap",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
# Täglich 03:15 — Abo-Ablauf prüfen (staggered weg von 03:00 poison_archive)
_scheduler.add_job( _scheduler.add_job(
_job_subscription_check, _job_subscription_check,
CronTrigger(hour=3, minute=0), CronTrigger(hour=3, minute=15),
id="subscription_check", id="subscription_check",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
_scheduler.add_job( _scheduler.add_job(
_job_invoice_reminder, _job_invoice_reminder,
@ -201,9 +229,10 @@ def start():
id="invoice_reminder", id="invoice_reminder",
replace_existing=True, replace_existing=True,
misfire_grace_time=3600, misfire_grace_time=3600,
coalesce=True,
) )
_scheduler.start() _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(): def stop():

View file

@ -8865,3 +8865,43 @@ svg.empty-state-icon {
overflow: hidden; overflow: hidden;
position: relative; position: relative;
} }
/* ============================================================
Offline-Bereitschafts-Anzeige IM Welten-FAB
Die 5 Pfoten-Pfade werden je nach Score grün gefärbt
(Default = weiß auf orange, filled = grün auf orange)
============================================================ */
#worlds-fab .offline-paw .paw-elem {
color: #fff; /* stroke via currentColor — fill bleibt 'none' aus HTML */
transition: stroke 0.4s ease;
}
#worlds-fab .offline-paw .paw-elem.filled {
color: #5C3517; /* dunkles Ban Yaro Braun — klar auf orangem FAB */
}
.offline-status-row {
display: flex;
align-items: center;
gap: var(--space-3);
padding: var(--space-2) var(--space-3);
border-radius: var(--radius-md);
border: 1px solid var(--c-border-light);
font-size: var(--text-sm);
margin-bottom: var(--space-2);
}
.offline-status-row .osr-check {
width: 24px;
height: 24px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 14px;
font-weight: 700;
}
.offline-status-row.ok .osr-check { background: var(--c-success); color: #fff; }
.offline-status-row.miss .osr-check { background: var(--c-surface-2); color: var(--c-text-muted); border: 1px dashed var(--c-border); }
.offline-status-row .osr-text { flex: 1; min-width: 0; }
.offline-status-row .osr-title { font-weight: 600; }
.offline-status-row .osr-detail { font-size: var(--text-xs); color: var(--c-text-muted); margin-top: 2px; }

View file

@ -101,9 +101,9 @@
</script> </script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung --> <!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=1070"> <link rel="stylesheet" href="/css/design-system.css?v=1090">
<link rel="stylesheet" href="/css/layout.css?v=1070"> <link rel="stylesheet" href="/css/layout.css?v=1090">
<link rel="stylesheet" href="/css/components.css?v=1070"> <link rel="stylesheet" href="/css/components.css?v=1090">
</head> </head>
<body> <body>
@ -602,7 +602,16 @@
<div class="world-panel" id="wp-welt"><div id="ww-content"></div></div> <div class="world-panel" id="wp-welt"><div id="ww-content"></div></div>
</div> </div>
<button id="worlds-fab" aria-label="Hinzufügen"> <button id="worlds-fab" aria-label="Hinzufügen">
<svg class="ph-icon" style="width:22px;height:22px"><use href="/icons/phosphor.svg#paw-print"></use></svg> <svg class="offline-paw" viewBox="0 0 256 256" aria-hidden="true" style="width:24px;height:24px">
<!-- 5 Sub-Pfade einzeln einfärbbar via .paw-elem; Default: weiß auf orange -->
<path class="paw-elem" data-step="1"
d="M128,104A36,36,0,0,0,93.43,130a43.49,43.49,0,0,1-20.67,25.9,32,32,0,0,0,27.73,57.62,72.49,72.49,0,0,1,55,0,32,32,0,0,0,27.73-57.62A43.46,43.46,0,0,1,162.57,130,36,36,0,0,0,128,104Z"
fill="none" stroke="currentColor" stroke-width="16" stroke-linecap="round" stroke-linejoin="round"/>
<circle class="paw-elem" data-step="2" cx="44" cy="108" r="20" fill="none" stroke="currentColor" stroke-width="16"/>
<circle class="paw-elem" data-step="3" cx="92" cy="60" r="20" fill="none" stroke="currentColor" stroke-width="16"/>
<circle class="paw-elem" data-step="4" cx="164" cy="60" r="20" fill="none" stroke="currentColor" stroke-width="16"/>
<circle class="paw-elem" data-step="5" cx="212" cy="108" r="20" fill="none" stroke="currentColor" stroke-width="16"/>
</svg>
</button> </button>
</div> </div>
<div id="worlds-back" aria-label="Zurück zur Welten-Navigation"> <div id="worlds-back" aria-label="Zurück zur Welten-Navigation">
@ -616,10 +625,11 @@
<div id="modal-container"></div> <div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features --> <!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=1070"></script> <script src="/js/api.js?v=1090"></script>
<script src="/js/ui.js?v=1070"></script> <script src="/js/ui.js?v=1090"></script>
<script src="/js/app.js?v=1070"></script> <script src="/js/app.js?v=1090"></script>
<script src="/js/worlds.js?v=1070"></script> <script src="/js/worlds.js?v=1090"></script>
<script src="/js/offline-indicator.js?v=1090"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -814,9 +814,57 @@ const API = (() => {
} catch {} } 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 // Öffentliche API
return { return {
get, post, put, patch, del, upload, swCacheDelete, get, post, put, patch, del, upload, swCacheDelete, compressImage,
auth, dogs, diary, health, tieraerzte, healthDocs, poison, auth, dogs, diary, health, tieraerzte, healthDocs, poison,
places, routes, walks, events, sitting, forum, lost, knigge, weather, push, places, routes, walks, events, sitting, forum, lost, knigge, weather, push,
friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes, friends, chat, webcal, importData, sharing, widget, notifications, services, ratings, sittingAccess, training, notes,

View file

@ -3,8 +3,10 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '1070'; // ← 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 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'; const IS_STAGING = location.hostname === 'staging.banyaro.app';
// Cache-Bust-Parameter nach Update-Reload sofort entfernen. // Cache-Bust-Parameter nach Update-Reload sofort entfernen.
// Flag MUSS vor replaceState gesetzt werden — index.html liest es danach. // Flag MUSS vor replaceState gesetzt werden — index.html liest es danach.

View file

@ -0,0 +1,263 @@
/* ============================================================
BAN YARO Offline-Bereitschafts-Anzeige IM Welten-FAB
Färbt die 5 Pfoten-Pfade je nach Cache-Stand grün:
1 = App-Shell · 2 = Wichtige Seiten · 3 = Hund-/Tagebuchdaten
4 = Karten-Tiles · 5 = Training & Wissen
============================================================ */
window.OfflineIndicator = (() => {
'use strict';
// Cache-Namen dynamisch finden — robust gegen Versions-Updates
const CACHE_TILES = 'ban-yaro-tiles-v1';
const CACHE_API = 'ban-yaro-api-v1';
const TILE_MIN = 50;
async function _staticCache() {
const names = await caches.keys();
const found = names.find(n => /^by-v\d+-static$/.test(n));
return found ? await caches.open(found) : null;
}
const CHECKS = [
{ step: 1, title: 'App-Grundgerüst',
detail: 'CSS, Layout und Hauptmodule — die Basis',
probe: async () => {
const c = await _staticCache();
if (!c) return false;
const urls = (await c.keys()).map(r => r.url);
return urls.some(u => u.includes('/css/design-system.css'))
&& urls.some(u => u.includes('/js/app.js'));
} },
{ step: 2, title: 'Wichtige Seiten',
detail: 'Tagebuch, Gesundheit, Karte, Gassi, Erste Hilfe, Notizen, Ausgaben, Routen',
probe: async () => {
const c = await _staticCache();
if (!c) return false;
const want = ['diary.js','health.js','map.js','walks.js','erste-hilfe.js',
'notes.js','expenses.js','routes.js'];
const urls = (await c.keys()).map(r => r.url);
const have = want.filter(name => urls.some(u => u.includes('/js/pages/' + name)));
return have.length >= want.length - 1; // 1 Toleranz (falls einzelner Fetch fehlschlug)
} },
{ step: 3, title: 'Hund-Daten',
detail: 'Profil, Tagebuch und Gesundheit',
probe: async () => {
const c = await caches.open(CACHE_API).catch(() => null);
if (!c) return false;
const urls = (await c.keys()).map(r => r.url);
const hasProfile = urls.some(u => /\/api\/dogs\/\d+(\?|$)/.test(u))
|| urls.some(u => /\/api\/dogs\/\d+\/welcome-dashboard/.test(u));
const hasDiary = urls.some(u => /\/api\/dogs\/\d+\/diary/.test(u));
const hasHealth = urls.some(u => /\/api\/dogs\/\d+\/health/.test(u));
// Profil + mindestens eine Datenquelle (Tagebuch oder Gesundheit)
return hasProfile && (hasDiary || hasHealth);
} },
{ step: 4, title: 'Weitere Listen',
detail: 'Ausgaben, Routen, Notizen',
probe: async () => {
const c = await caches.open(CACHE_API).catch(() => null);
if (!c) return false;
const urls = (await c.keys()).map(r => r.url);
return urls.some(u => u.includes('/api/expenses'))
&& urls.some(u => u.includes('/api/routes'))
&& urls.some(u => u.includes('/api/notes'));
} },
{ step: 5, title: 'Karten-Kacheln',
detail: `Mindestens ${TILE_MIN} OSM-Tiles im Umkreis`,
probe: async () => {
const c = await caches.open(CACHE_TILES).catch(() => null);
if (!c) return false;
return (await c.keys()).length >= TILE_MIN;
} },
];
// Tile-Prefetch-Konfiguration
const TILE_PREFETCH = [
{ zoom: 14, radius: 3 }, // 7x7 = 49 Tiles im Nahbereich
{ zoom: 13, radius: 1 }, // 3x3 = 9 Tiles für Übersicht
];
let _fab = null;
async function refresh() {
_fab = document.getElementById('worlds-fab');
if (!_fab || !('caches' in window)) return null;
const results = await Promise.all(CHECKS.map(async c => {
try { return { ...c, ok: await c.probe() }; }
catch { return { ...c, ok: false }; }
}));
_fab.querySelectorAll('.paw-elem').forEach(el => {
const step = Number(el.dataset.step);
const isOk = results.find(r => r.step === step)?.ok;
el.classList.toggle('filled', !!isOk);
});
const score = results.filter(r => r.ok).length;
_fab.setAttribute('data-offline-score', `${score}/5`);
return { score, results };
}
// Optional aufrufbar: zeigt das Status-Modal mit Nachlade-Button
async function openStatus() {
const data = await refresh();
if (!data) return;
const { score, results } = data;
const missing = results.filter(r => !r.ok);
const rows = results.map(r => `
<div class="offline-status-row ${r.ok ? 'ok' : 'miss'}">
<div class="osr-check">${r.ok ? '✓' : '○'}</div>
<div class="osr-text">
<div class="osr-title">${r.title}</div>
<div class="osr-detail">${r.detail}</div>
</div>
</div>
`).join('');
UI.modal.open({
title: `🐾 Offline-Bereitschaft ${score}/5`,
body: `
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin:0 0 var(--space-3)">
${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.'}
</p>
${rows}
`,
footer: `
<div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
${missing.length ? `<button class="btn btn-primary" id="offline-fill-btn" style="width:100%">
${UI.icon('cloud-arrow-down')} Fehlende jetzt nachladen
</button>` : ''}
<button class="btn btn-secondary" data-modal-close style="width:100%">Schließen</button>
</div>
`,
});
document.getElementById('offline-fill-btn')?.addEventListener('click', async () => {
const btn = document.getElementById('offline-fill-btn');
btn.disabled = true; btn.textContent = 'Lade …';
await _fetchMissing(missing);
UI.modal.close();
UI.toast.success('Offline-Inhalte aktualisiert.');
refresh();
});
}
async function _fetchMissing(missing) {
const tasks = [];
for (const m of missing) {
if (m.step === 2) {
['diary.js','map.js','walks.js','erste-hilfe.js','notes.js','expenses.js','routes.js'].forEach(p =>
tasks.push(fetch(`/js/pages/${p}?v=${window.APP_VER}`).catch(() => {})));
} else if (m.step === 3) {
const dogId = window._appState?.activeDog?.id;
if (dogId) {
tasks.push(fetch(`/api/dogs/${dogId}`).catch(() => {}));
tasks.push(fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {}));
tasks.push(fetch(`/api/dogs/${dogId}/health`).catch(() => {}));
}
} else if (m.step === 4) {
tasks.push(fetch('/api/expenses').catch(() => {}));
tasks.push(fetch('/api/routes').catch(() => {}));
tasks.push(fetch('/api/notes').catch(() => {}));
} else if (m.step === 5) {
await _prefetchTiles();
}
}
await Promise.all(tasks);
}
// ----------------------------------------------------------
// Tile-URL-Berechnung (OSM, Subdomain 'a')
// ----------------------------------------------------------
function _tile(lat, lon, z) {
const n = Math.pow(2, z);
const x = Math.floor((lon + 180) / 360 * n);
const latRad = lat * Math.PI / 180;
const y = Math.floor((1 - Math.log(Math.tan(latRad) + 1/Math.cos(latRad)) / Math.PI) / 2 * n);
return { x, y };
}
function _tileUrls(lat, lon, zoom, radius) {
const center = _tile(lat, lon, zoom);
const out = [];
for (let dx = -radius; dx <= radius; dx++) {
for (let dy = -radius; dy <= radius; dy++) {
out.push(`https://a.tile.openstreetmap.org/${zoom}/${center.x + dx}/${center.y + dy}.png`);
}
}
return out;
}
// Tile-Prefetch im Umkreis der aktuellen GPS-Position (nur wenn Permission schon da)
async function _prefetchTiles() {
if (!navigator.serviceWorker?.controller) return;
if (!navigator.permissions || !navigator.geolocation) return;
try {
const perm = await navigator.permissions.query({ name: 'geolocation' });
if (perm.state !== 'granted') return; // kein Popup wenn nicht schon erlaubt
const pos = await new Promise(res =>
navigator.geolocation.getCurrentPosition(p => res(p), () => res(null), { timeout: 5000 }));
if (!pos) return;
const urls = [];
TILE_PREFETCH.forEach(({ zoom, radius }) =>
urls.push(..._tileUrls(pos.coords.latitude, pos.coords.longitude, zoom, radius)));
navigator.serviceWorker.controller.postMessage({ type: 'CACHE_TILES', urls });
} catch {}
}
// Page-Module proaktiv fetchen — falls SW-Install sie noch nicht alle hatte
function _prefetchPages() {
['diary','health','map','walks','erste-hilfe','notes','expenses','routes','poison','lost']
.forEach(p => fetch(`/js/pages/${p}.js?v=${window.APP_VER}`).catch(() => {}));
}
// Daten-Prefetch: Listen die offline brauchbar sein müssen,
// auch wenn der User die Seiten noch nie geöffnet hat
function _prefetchData() {
fetch('/api/expenses').catch(() => {});
fetch('/api/routes').catch(() => {});
fetch('/api/notes').catch(() => {});
const dogId = window._appState?.activeDog?.id;
if (dogId) {
fetch(`/api/dogs/${dogId}`).catch(() => {});
fetch(`/api/dogs/${dogId}/health`).catch(() => {});
fetch(`/api/dogs/${dogId}/diary?limit=20`).catch(() => {});
}
}
function init() {
refresh();
_prefetchPages();
_prefetchTiles();
_prefetchData();
// Mehrere Retries für hund-spezifische Daten — _appState.activeDog wird oft
// erst nach Login/Hunde-Load gesetzt, manchmal mehrere Sekunden nach Init
[2_000, 5_000, 10_000, 20_000].forEach(delay => {
setTimeout(() => { _prefetchData(); refresh(); }, delay);
});
if (navigator.serviceWorker) {
navigator.serviceWorker.addEventListener('message', e => {
if (e?.data?.type === 'CACHE_UPDATE') refresh();
if (e?.data?.type === 'CACHE_TILES_PROGRESS') refresh();
});
}
setInterval(refresh, 60_000);
}
return { init, refresh, openStatus };
})();
if (document.readyState !== 'loading') {
window.OfflineIndicator.init();
} else {
document.addEventListener('DOMContentLoaded', () => window.OfflineIndicator.init());
}

View file

@ -8,6 +8,7 @@ window.Page_admin = (() => {
let _container = null; let _container = null;
let _appState = null; let _appState = null;
let _tab = 'uebersicht'; let _tab = 'uebersicht';
let _delegationAttached = null; // WeakRef-Ersatz: zuletzt mit Delegation versehener Container
const TABS = [ const TABS = [
{ id: 'uebersicht', label: 'Übersicht', icon: 'house-line' }, { id: 'uebersicht', label: 'Übersicht', icon: 'house-line' },
@ -71,20 +72,43 @@ window.Page_admin = (() => {
_container.querySelector('#adm-tabs') _container.querySelector('#adm-tabs')
?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2)); ?.style.setProperty('--adm-tab-cols', Math.ceil(TABS.length / 2));
_container.querySelectorAll('#adm-tabs .by-tab').forEach(btn => {
btn.addEventListener('click', () => { // Event-Delegation: Listener EINMAL pro Container-Instanz setzen
_tab = btn.dataset.tab; // (innerHTML überschreibt zwar das DOM, aber bei jedem init() würden ohne
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b => // Flag erneut N=18 Tab-Listener akkumuliert werden)
b.classList.toggle('active', b.dataset.tab === _tab) if (_delegationAttached !== _container) {
); _container.addEventListener('click', _onContainerClick);
_renderTab(); _delegationAttached = _container;
}); }
});
_renderActionItems(); _renderActionItems();
_renderTab(); _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() { async function _renderActionItems() {
const el = _container.querySelector('#adm-action-items'); const el = _container.querySelector('#adm-action-items');
if (!el) return; if (!el) return;
@ -134,15 +158,7 @@ window.Page_admin = (() => {
</span> </span>
</div>`; </div>`;
el.querySelectorAll('[data-action-tab]').forEach(btn => { // Klicks auf [data-action-tab] werden zentral via _onContainerClick (Delegation) behandelt
btn.addEventListener('click', () => {
_tab = btn.dataset.actionTab;
_container.querySelectorAll('#adm-tabs .by-tab').forEach(b =>
b.classList.toggle('active', b.dataset.tab === _tab)
);
_renderTab();
});
});
} }
async function _renderTab() { async function _renderTab() {
@ -820,7 +836,13 @@ window.Page_admin = (() => {
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px"> <div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
🗺 ${u.route_count} Routen · ${u.total_km} km 🗺 ${u.route_count} Routen · ${u.total_km} km
· 📍 ${u.poi_count} POIs · 📍 ${u.poi_count} POIs
${u.last_route ? '· zuletzt ' + new Date(u.last_route).toLocaleDateString('de-DE') : ''} </div>
<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">
${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'}
</div> </div>
</div> </div>
@ -3547,6 +3569,15 @@ window.Page_admin = (() => {
</div> </div>
</div> </div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)"> <div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3)">
${r.existing_invoice_id ? `
<button class="btn adm-invoice-edit-btn"
data-invoice-id="${r.existing_invoice_id}"
title="Rechnung ${_esc(r.existing_invoice_number)} (${_esc(r.existing_invoice_status)}) bearbeiten"
style="background:#eab308;color:#1a1a1a;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600">
${UI.icon('receipt')} Rechnung bearbeiten
</button>` : `
<button class="btn adm-invoice-btn" <button class="btn adm-invoice-btn"
data-name="${_esc(r.name)}" data-email="${_esc(r.email)}" data-name="${_esc(r.name)}" data-email="${_esc(r.email)}"
data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}" data-tier="${r.tier}" data-address="${_esc(r.billing_address || '')}"
@ -3557,7 +3588,7 @@ window.Page_admin = (() => {
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
cursor:pointer;font-size:var(--text-sm);font-weight:600"> cursor:pointer;font-size:var(--text-sm);font-weight:600">
${UI.icon('receipt')} Rechnung erstellen ${UI.icon('receipt')} Rechnung erstellen
</button> </button>`}
<button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}" <button class="btn adm-fulfill-btn" data-id="${r.id}" data-name="${_esc(r.name)}" data-tier="${r.tier}"
style="background:#16a34a;color:#fff;border:none; style="background:#16a34a;color:#fff;border:none;
padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
@ -3677,6 +3708,29 @@ window.Page_admin = (() => {
}); });
}); });
}); });
// "Rechnung bearbeiten" — lädt existierenden Entwurf/Sent-Rechnung im Edit-Modus
el.querySelectorAll('.adm-invoice-edit-btn').forEach(btn => {
btn.addEventListener('click', async () => {
try {
const inv = await API.get(`/admin/invoices/${btn.dataset.invoiceId}`);
_openNeueRechnungModal(() => {
// Nach Speichern/Stornieren: zurück auf Upgrades-Tab, damit der Button neu rendert
_renderTab();
}, {
recipient_name: inv.recipient_name,
recipient_email: inv.recipient_email,
recipient_address: inv.recipient_address || '',
service_period: inv.service_period || '',
discount_pct: inv.discount_pct || 0,
notes: inv.notes || '',
items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })),
}, inv.id, inv.status, inv.invoice_number);
} catch (e) {
UI.toast.error(e.message || 'Rechnung konnte nicht geladen werden.');
}
});
});
} }
// ------------------------------------------------------------------ // ------------------------------------------------------------------
@ -3869,7 +3923,7 @@ window.Page_admin = (() => {
discount_pct: inv.discount_pct || 0, discount_pct: inv.discount_pct || 0,
notes: inv.notes || '', notes: inv.notes || '',
items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })), items: inv.items.map(it => ({ description: it.description, quantity: it.quantity, unit_price: it.unit_price })),
}, inv.id); }, inv.id, inv.status, inv.invoice_number);
}); });
}); });
@ -3889,16 +3943,31 @@ window.Page_admin = (() => {
}); });
} }
function _openNeueRechnungModal(reload, prefill = null, invoiceId = null) { function _openNeueRechnungModal(reload, prefill = null, invoiceId = null, status = null, invoiceNumber = null) {
const id = `inv-new-${Date.now()}`; const id = `inv-new-${Date.now()}`;
const p = prefill || {}; const p = prefill || {};
const isEdit = !!invoiceId; const isEdit = !!invoiceId;
const isLocked = isEdit && status && status !== 'draft'; // sent / paid / cancelled → nicht änderbar
const canCancel = isEdit && status !== 'cancelled'; // alles außer schon storniert
const lockedBanner = isLocked ? (() => {
const map = {
sent: ['#fff8f0', '#f0a060', '#c05000', 'Diese Rechnung wurde bereits versendet — Inhalt nicht mehr änderbar. Korrekturen nur per Stornierung.'],
paid: ['#f0fdf4', '#86efac', '#15803d', 'Diese Rechnung ist bezahlt — Inhalt nicht änderbar.'],
cancelled: ['#fef2f2', '#fca5a5', '#b91c1c', 'Diese Rechnung wurde storniert — Inhalt nicht änderbar.'],
};
const [bg, br, fg, msg] = map[status] || ['#f5f5f5', '#ccc', '#444', `Status: ${status}`];
return `<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:${bg};border:1px solid ${br};
font-size:var(--text-xs);color:${fg};line-height:1.6">${msg}</div>`;
})() : '';
UI.modal.open({ UI.modal.open({
title: `${UI.icon('receipt')} ${isEdit ? 'Rechnung bearbeiten' : 'Neue Rechnung erstellen'}`, title: `${UI.icon('receipt')} ${isEdit ? (isLocked ? 'Rechnung ansehen' : 'Rechnung bearbeiten') : 'Neue Rechnung erstellen'}`,
body: ` body: `
<form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)"> <form id="${id}" style="display:flex;flex-direction:column;gap:var(--space-3)">
${lockedBanner}
${!isEdit && !p.recipient_name ? ` ${!isEdit && !p.recipient_name ? `
<div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md); <div style="padding:var(--space-2) var(--space-3);border-radius:var(--radius-md);
background:#fff8f0;border:1px solid #f0a060; background:#fff8f0;border:1px solid #f0a060;
@ -3977,11 +4046,43 @@ window.Page_admin = (() => {
</form> </form>
`, `,
footer: ` footer: `
<button class="btn btn-secondary" data-modal-close>Abbrechen</button> <div style="display:flex;flex-direction:column;gap:var(--space-2);width:100%">
<button class="btn btn-primary" form="${id}" type="submit">${UI.icon('receipt')} ${isEdit ? 'Änderungen speichern' : 'Rechnung erstellen'}</button> ${isLocked
? `<button class="btn btn-secondary" data-modal-close style="width:100%">Schließen</button>`
: `<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2)">
<button class="btn btn-secondary" data-modal-close>Abbrechen</button>
<button class="btn btn-primary" form="${id}" type="submit" style="min-width:0">
${UI.icon('receipt')} ${isEdit ? 'Speichern' : 'Erstellen'}
</button>
</div>`}
${canCancel ? `
<button class="btn btn-ghost" id="${id}-cancel-invoice"
style="width:100%;color:var(--c-danger);border:1px solid var(--c-danger)">
${UI.icon('x-circle')} Rechnung stornieren
</button>` : ''}
</div>
`, `,
}); });
// 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 // Items-Container und Hilfsfunktionen
const itemsContainer = document.getElementById(`${id}-items`); const itemsContainer = document.getElementById(`${id}-items`);
const previewEl = document.getElementById(`${id}-preview`); const previewEl = document.getElementById(`${id}-preview`);
@ -4046,6 +4147,7 @@ window.Page_admin = (() => {
// Form Submit // Form Submit
document.getElementById(id)?.addEventListener('submit', async e => { document.getElementById(id)?.addEventListener('submit', async e => {
e.preventDefault(); e.preventDefault();
if (isLocked) return; // gesperrte Rechnung — Submit ignorieren (Button ist eh ausgeblendet)
const fd = new FormData(e.target); const fd = new FormData(e.target);
const items = []; const items = [];
itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => { itemsContainer.querySelectorAll('.adm-inv-item-row').forEach(row => {

View file

@ -1437,25 +1437,33 @@ window.Page_diary = (() => {
wrap.innerHTML = grid; wrap.innerHTML = grid;
wrap.querySelectorAll('.diary-media-thumb-del').forEach(btn => { wrap.querySelectorAll('.diary-media-thumb-del').forEach(btn => {
btn.addEventListener('click', async () => { 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 mediaId = btn.dataset.mediaId ? parseInt(btn.dataset.mediaId) : null;
const isLegacy = !!btn.dataset.legacy; const isLegacy = !!btn.dataset.legacy;
btn.disabled = true; btn.disabled = true;
let alreadyGone = false;
try { try {
if (mediaId != null) { if (mediaId != null) {
await API.diary.deleteMediaItem(_appState.activeDog.id, entry.id, mediaId); 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) { } else if (isLegacy) {
await API.diary.deleteMedia(_appState.activeDog.id, entry.id); await API.diary.deleteMedia(_appState.activeDog.id, entry.id);
entry.media_url = null;
} }
wrap2.remove();
UI.toast.success('Medium entfernt.');
} catch (e) { } catch (e) {
btn.disabled = false; if (e?.status === 404) {
UI.toast.error(e.message || 'Fehler beim Löschen.'); 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 // Stern-Buttons im Edit-Formular
@ -1722,29 +1730,37 @@ window.Page_diary = (() => {
}; };
async function _uploadNewFiles(entryId) { async function _uploadNewFiles(entryId) {
let failCount = 0; const total = _newFiles.length;
const uploaded = []; const saveBtn = document.querySelector('button[form="diary-form"]');
let exifGps = null; let done = 0;
for (const file of _newFiles) { if (saveBtn) saveBtn.textContent = `0 von ${total} hochgeladen…`;
const results = await Promise.all(_newFiles.map(async file => {
// Bild-Kompression vor Upload (HEIC/Video/<500KB werden unverändert durchgereicht)
const toUpload = await API.compressImage(file);
const formData = new FormData();
formData.append('file', toUpload);
try { try {
const formData = new FormData();
formData.append('file', file);
const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData); const m = await API.diary.uploadMedia(_appState.activeDog.id, entryId, formData);
uploaded.push(m); if (saveBtn) saveBtn.textContent = `${++done} von ${total} hochgeladen…`;
if (m.exif_lat != null && m.exif_lon != null && !exifGps) { return { ok: true, m };
exifGps = { lat: m.exif_lat, lon: m.exif_lon };
}
} catch { } 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) { if (failCount > 0) {
UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`); UI.toast.warning(`${failCount} Medium${failCount > 1 ? 'en' : ''} konnte${failCount > 1 ? 'n' : ''} nicht hochgeladen werden.`);
} }
if (exifGps) { if (exifGps) {
UI.toast.success(`📍 Standort aus Foto-GPS übernommen`); 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) { if (isEdit) {

View file

@ -762,8 +762,10 @@ window.Page_dog_profile = (() => {
const file = e.target.files[0]; const file = e.target.files[0];
if (!file) return; if (!file) return;
try { try {
// Client-Side-Kompression vor Upload (HEIC bleibt unverändert)
const toUpload = await API.compressImage(file);
const fd = new FormData(); const fd = new FormData();
fd.append('file', file); fd.append('file', toUpload);
const result = await API.dogs.uploadPhoto(dog.id, fd); const result = await API.dogs.uploadPhoto(dog.id, fd);
// Position zurücksetzen // Position zurücksetzen
await API.dogs.updatePhotoPosition(dog.id, 1.0, 0.0, 0.0); 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 // Foto hochladen wenn gewählt
if (fotoFile) { if (fotoFile) {
try { try {
// Client-Side-Kompression vor Upload
const toUpload = await API.compressImage(fotoFile);
const fd = new FormData(); const fd = new FormData();
fd.append('file', fotoFile); fd.append('file', toUpload);
const result = await API.dogs.uploadPhoto(saved.id, fd); const result = await API.dogs.uploadPhoto(saved.id, fd);
saved.foto_url = result.foto_url; saved.foto_url = result.foto_url;
_appState.activeDog = { ...saved }; _appState.activeDog = { ...saved };

View file

@ -1263,8 +1263,9 @@ window.Page_health = (() => {
if (!saved.media_items) saved.media_items = []; if (!saved.media_items) saved.media_items = [];
for (const f of files) { for (const f of files) {
try { try {
const toUpload = await API.compressImage(f);
const fd = new FormData(); const fd = new FormData();
fd.append('file', f); fd.append('file', toUpload);
const res = await API.health.uploadMedia(dogId, saved.id, fd); 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 }); 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 // Rückwärtskompatibilität: erste Datei auch als datei_url sichern
@ -2739,6 +2740,8 @@ window.Page_health = (() => {
} }
await UI.asyncButton(btn, async () => { 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(); const formData = new FormData();
formData.append('dog_id', String(dog.id)); formData.append('dog_id', String(dog.id));
formData.append('typ', fd.typ); formData.append('typ', fd.typ);
@ -2746,7 +2749,7 @@ window.Page_health = (() => {
formData.append('beschreibung', fd.beschreibung || ''); formData.append('beschreibung', fd.beschreibung || '');
formData.append('datum', fd.datum || ''); formData.append('datum', fd.datum || '');
if (fd.vet_id) formData.append('vet_id', fd.vet_id); if (fd.vet_id) formData.append('vet_id', fd.vet_id);
formData.append('file', file); formData.append('file', toUpload);
try { try {
const doc = await API.healthDocs.upload(formData); const doc = await API.healthDocs.upload(formData);

View file

@ -772,8 +772,9 @@ window.Page_lost = (() => {
// Foto hochladen // Foto hochladen
if (photoInput?.files[0]) { if (photoInput?.files[0]) {
try { try {
const toUpload = await API.compressImage(photoInput.files[0]);
const formData = new FormData(); const formData = new FormData();
formData.append('file', photoInput.files[0]); formData.append('file', toUpload);
const media = await API.lost.uploadFoto(created.id, formData); const media = await API.lost.uploadFoto(created.id, formData);
created.foto_url = media.foto_url; created.foto_url = media.foto_url;
} catch { } catch {

View file

@ -552,8 +552,9 @@ window.Page_poison = (() => {
// Foto hochladen // Foto hochladen
if (photoInput?.files[0]) { if (photoInput?.files[0]) {
try { try {
const toUpload = await API.compressImage(photoInput.files[0]);
const formData = new FormData(); const formData = new FormData();
formData.append('file', photoInput.files[0]); formData.append('file', toUpload);
const media = await API.poison.uploadPhoto(created.id, formData); const media = await API.poison.uploadPhoto(created.id, formData);
created.foto_url = media.foto_url; created.foto_url = media.foto_url;
} catch { } catch {

View file

@ -628,8 +628,10 @@ window.Page_walks = (() => {
document.getElementById('wd-photo-input')?.addEventListener('change', async function() { document.getElementById('wd-photo-input')?.addEventListener('change', async function() {
if (!this.files.length) return; if (!this.files.length) return;
const file = this.files[0]; const file = this.files[0];
// Client-Side-Kompression vor Upload (HEIC bleibt unverändert)
const toUpload = await API.compressImage(file);
const formData = new FormData(); const formData = new FormData();
formData.append('file', file); formData.append('file', toUpload);
try { try {
const photo = await API.walks.uploadPhoto(walk.id, formData); const photo = await API.walks.uploadPhoto(walk.id, formData);
const grid = document.getElementById('wd-photos-grid'); const grid = document.getElementById('wd-photos-grid');

View file

@ -4,19 +4,22 @@
============================================================ */ ============================================================ */
// ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab
const VER = '1070'; const VER = '1090';
const CACHE_VERSION = `by-v${VER}`; const CACHE_VERSION = `by-v${VER}`;
const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_STATIC = `${CACHE_VERSION}-static`;
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
// Prioritäts-Seiten: werden nach Install im Hintergrund gecacht (nicht blockierend) // 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 = [ const PRIORITY_PAGES = [
'/js/pages/admin.js',
'/js/pages/erste-hilfe.js',
'/js/pages/diary.js', '/js/pages/diary.js',
'/js/pages/health.js',
'/js/pages/map.js', '/js/pages/map.js',
'/js/pages/walks.js', '/js/pages/walks.js',
'/js/pages/erste-hilfe.js',
'/js/pages/notes.js',
'/js/pages/expenses.js',
'/js/pages/routes.js', '/js/pages/routes.js',
'/js/pages/poison.js', '/js/pages/poison.js',
'/js/pages/lost.js', '/js/pages/lost.js',
@ -32,6 +35,7 @@ const STATIC_ASSETS = [
`/js/ui.js?v=${VER}`, `/js/ui.js?v=${VER}`,
`/js/app.js?v=${VER}`, `/js/app.js?v=${VER}`,
`/js/worlds.js?v=${VER}`, `/js/worlds.js?v=${VER}`,
`/js/offline-indicator.js?v=${VER}`,
'/js/leaflet.markercluster.js', '/js/leaflet.markercluster.js',
'/css/MarkerCluster.css', '/css/MarkerCluster.css',
'/css/MarkerCluster.Default.css', '/css/MarkerCluster.Default.css',
@ -132,6 +136,26 @@ function _isMediaUpload(request) {
return (request.headers.get('Content-Type') || '').includes('multipart'); 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? // Welche GET-API-Endpoints sollen gecacht werden?
const _CACHEABLE_GET = [ const _CACHEABLE_GET = [
/^\/api\/dogs(\/\d+)?$/, /^\/api\/dogs(\/\d+)?$/,
@ -151,6 +175,7 @@ const _CACHEABLE_GET = [
/^\/api\/walks/, /^\/api\/walks/,
/^\/api\/lost/, /^\/api\/lost/,
/^\/api\/expenses/, /^\/api\/expenses/,
/^\/api\/notes/,
// Drei Welten — offline-fähig // Drei Welten — offline-fähig
/^\/api\/streak\/\d+/, /^\/api\/streak\/\d+/,
/^\/api\/forum\/threads/, /^\/api\/forum\/threads/,
@ -315,14 +340,18 @@ self.addEventListener('fetch', event => {
return; return;
} }
// OSM-Kartenkacheln: eigener persistenter Cache // OSM-Kartenkacheln: eigener persistenter Cache (Cache-First mit LRU-Eviction)
if (url.hostname.endsWith('tile.openstreetmap.org')) { if (url.hostname.endsWith('tile.openstreetmap.org')) {
event.respondWith( event.respondWith(
caches.open(CACHE_TILES).then(cache => caches.open(CACHE_TILES).then(cache =>
cache.match(event.request).then(cached => { cache.match(event.request).then(cached => {
if (cached) return cached; if (cached) return cached;
return fetch(event.request).then(response => { 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; return response;
}); });
}) })
@ -435,6 +464,7 @@ self.addEventListener('message', event => {
function fetchBatch() { function fetchBatch() {
if (queue.length === 0) { if (queue.length === 0) {
source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: total, total }); source?.postMessage({ type: 'CACHE_TILES_PROGRESS', done: total, total });
trimTileCache(); // nach Bulk-Vorausladen einmal trimmen
return; return;
} }
const batch = queue.splice(0, 8); const batch = queue.splice(0, 8);