Compare commits

..

38 commits

Author SHA1 Message Date
d18c592ef0 Fix: rasse-erkennung ANTHROPIC_KEY zur Laufzeit prüfen (SW by-v826) 2026-05-10 10:34:28 +02:00
36ccd7815e Fix: Zoom-Control auf Tablet 28px runter (leaflet-top padding-top) (SW by-v825) 2026-05-10 10:09:37 +02:00
666cdd3a73 Fix: map-legend top: 28px global (kein Media-Query), deckt alle Geräte ab (SW by-v824) 2026-05-10 10:03:58 +02:00
d078fb4b51 Fix: Karte Filter-Tabs Tablet top: 28px fix (kein env/max) (SW by-v823) 2026-05-10 09:50:52 +02:00
47bb1e202e Fix: Karte Filter-Tabs Tablet min 20px top (iPad ohne Notch hat kein safe-area-inset) (SW by-v822) 2026-05-10 09:45:03 +02:00
c96e6e1fac Fix: Karte Tablet — map füllt voll, Filter-Tabs unter Statusleiste (safe-top) (SW by-v821) 2026-05-10 09:38:36 +02:00
c185193ac9 Fix: Karte Filter-Tabs Tablet safe-area-inset-top auf Desktop-Layout (SW by-v820) 2026-05-10 09:29:01 +02:00
25417364d2 Fix: kein zweiter SW-Reload nach force-update (by_skip_sw_reload Flag), Tagebuch bleibt offen (SW by-v819) 2026-05-10 09:04:12 +02:00
9139e33492 Fix: Übungen Hilfetext standardmäßig geschlossen (defaultClosed), nur per ? öffnen (SW by-v818) 2026-05-10 08:57:49 +02:00
b4fec76644 Fix: Update-Zielseite nach Reload wiederherstellen, Toast nach 800ms (SW by-v817) 2026-05-10 08:50:29 +02:00
183cc564fc Fix: Auto-Update wartet bis kein Modal offen — kein Datenverlust bei Tagebucheintrag (SW by-v816) 2026-05-10 08:42:06 +02:00
effdf5ba5b Fix: Android Statusleiste immer dunkel (#0f1623), kein Amber-Streifen mehr (SW by-v815) 2026-05-10 08:34:18 +02:00
5a30f657a1 Feature: Android Beta-Tester Banner für bestehende Nutzer (SW by-v814) 2026-05-10 08:20:59 +02:00
06022cf5da Feature: Stilles Auto-Update beim Seitenwechsel + Toast 'App auf v813 aktualisiert', Banner entfernt (SW by-v813) 2026-05-09 22:20:23 +02:00
047e5be986 Fix: theme-color ein Tag mit id, inline-Script setzt Farbe sofort beim Laden — kein amber Streifen auf Samsung (SW by-v812) 2026-05-09 22:14:06 +02:00
1fdd7d4ed0 Fix: theme-color JS-Fallback für Samsung, Wischgesten-Tipp in Settings, _syncThemeColor bei init (SW by-v811) 2026-05-09 22:07:01 +02:00
480a343ec0 Fix: theme-color dunkel im Dark-Mode (Statusleiste Samsung), manifest background_color dunkel (SW by-v810) 2026-05-09 21:59:20 +02:00
b4879d615f Feature: KI-Berichte einzeln durchblättern (‹ Älter / Neuer ›), Navigation per Index (SW by-v809) 2026-05-09 21:50:27 +02:00
3acb7aa874 Debug: KI-Bericht save_count + save_error im Response, Toast-Feedback (SW by-v808) 2026-05-09 21:38:35 +02:00
891e11df65 Fix: ki_health_reports ALTER TABLE user_id Migration, INSERT try/catch, Bericht immer zurückgeben (SW by-v807) 2026-05-09 21:31:05 +02:00
0b06669635 Test: v806 Update-Mechanismus verifiziert — beide Geräte erfolgreich (SW by-v806) 2026-05-09 21:26:53 +02:00
af2851a4ac Fix: index.html JS ?v=786 → ?v=805, CSS ?v=788 → ?v=805 — das war die Ursache aller SW-Update-Probleme seit v786 (SW by-v805) 2026-05-09 21:16:38 +02:00
5f1a6d578b Fix: ki_health_reports INSERT user_id ergänzt, SW updatefound Race Fix (SW by-v805) 2026-05-09 21:08:25 +02:00
ab851d4bb1 Fix: SW updatefound Listener vor reg.update() + race condition safety (SW by-v804) 2026-05-09 20:58:07 +02:00
fbd8f0cd8f Fix: ki-berichte aus SW-Cache ausgenommen, force-Reload nach Generierung, Berichte alle sichtbar (SW by-v803) 2026-05-09 20:48:35 +02:00
322a9609b3 Fix: Update-Button → /force-update (kein _nocache-Loop), force-update fire-and-forget ohne await (SW by-v802) 2026-05-09 20:41:57 +02:00
626148436a Fix: KI-Gesundheitsbericht speichern + Berichte-Liste nach Generierung aktualisieren (SW by-v801) 2026-05-09 20:35:02 +02:00
6eb333bb01 Fix: KI-Gesundheitsbericht nach Generierung in ki_health_reports speichern 2026-05-09 20:28:24 +02:00
2a545377c1 Fix: Update-Loop entfernt (navigate kein Reload mehr), API-Header triggert Banner statt Redirect (SW by-v800) 2026-05-09 20:18:45 +02:00
e20e691c4d Fix: KI-Endpoints + Symptom-Check am SW-Timeout vorbei (kein 10s Limit für LLM) — SW by-v799 2026-05-09 20:12:53 +02:00
c1bcc029ea Fix: KI health_summary Kontextfenster (max 5 Einträge), Cloud-Priority ohne ANTHROPIC_KEY-Check, local-Fallback wrapped (SW by-v798) 2026-05-09 20:08:06 +02:00
d91cc8da26 Feature: X-App-Version Header + api.js auto-reload bei navigate() — kein Banner-Click mehr nötig (SW by-v798) 2026-05-09 19:55:13 +02:00
67f042df75 Fix: SW-Update via updatefound+statechange (primär), controllerchange (Backup) — SW by-v797 2026-05-09 19:47:14 +02:00
209d6703ad Fix: Media-Uploads direkt ans Netz (kein SW-Clone), SW _nocache-Bypass, Samsung-Dark-Mode-Hint, Update-Button fire-and-forget (SW by-v796) 2026-05-09 19:07:52 +02:00
5949a07b28 Security: Play Store Paket aus Git entfernen (signing.keystore nicht eincheckbar) 2026-05-09 18:10:13 +02:00
97a03ce006 Fix: SW-Update nuklear (unregister all), touch-action pan-y für Scroll, /force-update Route, Geburtstags-KI user_id, konto-loeschen Import (SW by-v791) 2026-05-09 18:09:53 +02:00
f73a2bdeab Feature: /konto-loeschen Seite für Play Store Datenschutz-Pflicht 2026-05-08 21:06:40 +02:00
e4b170d45b Feature: Konto löschen (Play Store Pflicht) — DELETE /profile/account + Button in Settings (SW by-v786) 2026-05-08 21:03:05 +02:00
19 changed files with 345 additions and 166 deletions

1
.gitignore vendored
View file

@ -11,3 +11,4 @@ __pycache__/
# Design-Quell-Dateien (nicht für Server) # Design-Quell-Dateien (nicht für Server)
/icons/ /icons/
.claude/worktrees/ .claude/worktrees/
Ban Yaro - Google Play package/

View file

@ -1236,11 +1236,16 @@ def _migrate(conn_factory):
CREATE TABLE IF NOT EXISTS ki_health_reports ( CREATE TABLE IF NOT EXISTS ki_health_reports (
id INTEGER PRIMARY KEY AUTOINCREMENT, id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE, dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
bericht TEXT NOT NULL, bericht TEXT NOT NULL,
erstellt_at TEXT NOT NULL DEFAULT (datetime('now')) erstellt_at TEXT NOT NULL DEFAULT (datetime('now'))
) )
""") """)
# user_id nachträglich ergänzen falls Tabelle ohne diese Spalte erstellt wurde
try:
conn.execute("ALTER TABLE ki_health_reports ADD COLUMN user_id INTEGER REFERENCES users(id) ON DELETE CASCADE")
except Exception:
pass # Spalte existiert bereits
conn.execute(""" conn.execute("""
CREATE INDEX IF NOT EXISTS idx_ki_health_reports_dog CREATE INDEX IF NOT EXISTS idx_ki_health_reports_dog
ON ki_health_reports(dog_id, erstellt_at DESC) ON ki_health_reports(dog_id, erstellt_at DESC)

View file

@ -84,7 +84,7 @@ def _track_usage(user_id: int | None, source: str) -> None:
def _is_cloud_priority_user(user_id: int | None) -> bool: def _is_cloud_priority_user(user_id: int | None) -> bool:
"""Privilegierte Rollen (Admin, Moderator, Züchter, Manager) nutzen Cloud-KI primär.""" """Privilegierte Rollen (Admin, Moderator, Züchter, Manager) nutzen Cloud-KI primär."""
if not user_id or not ANTHROPIC_KEY: if not user_id:
return False return False
try: try:
from database import db from database import db
@ -173,8 +173,10 @@ async def complete(
raise raise
except Exception as e: except Exception as e:
logger.warning(f"Cloud-KI nicht erreichbar für privilegierten User, Fallback lokal: {e}") logger.warning(f"Cloud-KI nicht erreichbar für privilegierten User, Fallback lokal: {e}")
# Fallback auf lokales Modell try:
text = await _local_complete(prompt, system, max_tokens, json_mode) text = await _local_complete(prompt, system, max_tokens, json_mode)
except Exception as local_e:
raise KIUnavailableError("KI-Modell nicht erreichbar.") from local_e
_track_usage(user_id, "local") _track_usage(user_id, "local")
if return_model: if return_model:
return (text, LOCAL_MODEL) return (text, LOCAL_MODEL)
@ -399,7 +401,7 @@ async def health_summary(health_data: list, dog_info: dict,
if not subset: if not subset:
return " (keine Einträge)" return " (keine Einträge)"
lines = [] lines = []
for e in subset[:10]: # maximal 10 pro Typ for e in subset[:5]: # maximal 5 pro Typ — Kontextfenster schonen
line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}" line = f" - {e.get('datum', '?')}: {e.get('bezeichnung', '?')}"
if e.get("naechstes"): if e.get("naechstes"):
line += f" (nächste Fälligkeit: {e['naechstes']})" line += f" (nächste Fälligkeit: {e['naechstes']})"

View file

@ -140,6 +140,20 @@ class _UploadSizeMiddleware(BaseHTTPMiddleware):
app.add_middleware(_UploadSizeMiddleware) app.add_middleware(_UploadSizeMiddleware)
class _AppVersionMiddleware(BaseHTTPMiddleware):
"""Fügt X-App-Version zu allen /api/-Antworten hinzu.
api.js erkennt damit sofort wenn eine neue Version deployed wurde
und lädt beim nächsten Seitenwechsel automatisch neu kein Banner nötig.
"""
async def dispatch(self, request: Request, call_next):
response = await call_next(request)
if request.url.path.startswith('/api/'):
response.headers['X-App-Version'] = APP_VER
return response
app.add_middleware(_AppVersionMiddleware)
class _CacheControlMiddleware(BaseHTTPMiddleware): class _CacheControlMiddleware(BaseHTTPMiddleware):
"""Setzt Cache-Control-Header für statische Assets. """Setzt Cache-Control-Header für statische Assets.
CSS/JS: no-cache (ETag-Validierung) iOS cached sonst ewig ohne Ablaufdatum. CSS/JS: no-cache (ETag-Validierung) iOS cached sonst ewig ohne Ablaufdatum.
@ -327,7 +341,7 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
os.makedirs(MEDIA_DIR, exist_ok=True) os.makedirs(MEDIA_DIR, exist_ok=True)
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "785" # muss mit APP_VER in app.js übereinstimmen APP_VER = "826" # 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():
@ -1544,6 +1558,84 @@ async def presse():
return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"}) return FileResponse(f"{STATIC_DIR}/presse.html", headers={"Cache-Control": "max-age=3600"})
@app.get("/konto-loeschen")
async def konto_loeschen():
from fastapi.responses import HTMLResponse
html = """<!DOCTYPE html>
<html lang="de">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Konto löschen Ban Yaro</title>
<link rel="stylesheet" href="/css/design-system.css">
<style>
body { font-family: var(--font-sans); background: var(--c-bg); color: var(--c-text);
max-width: 600px; margin: 0 auto; padding: 2rem 1.5rem; }
h1 { font-size: 1.6rem; font-weight: 800; margin-bottom: 0.5rem; }
p { color: var(--c-text-secondary); line-height: 1.6; margin-bottom: 1rem; }
ol { color: var(--c-text-secondary); line-height: 2; padding-left: 1.5rem; }
.btn { display: inline-flex; align-items: center; gap: 0.5rem;
background: var(--c-primary); color: #fff; border: none;
border-radius: 100px; padding: 0.75rem 1.5rem;
font-size: 1rem; font-weight: 600; text-decoration: none;
margin-top: 1.5rem; cursor: pointer; }
.warn { background: var(--c-danger-subtle); border: 1px solid var(--c-danger-border);
border-radius: 12px; padding: 1rem 1.25rem; margin-bottom: 1.5rem;
color: var(--c-danger); font-size: 0.9rem; }
</style>
</head>
<body>
<p style="margin-bottom:2rem"><a href="/" style="color:var(--c-primary);text-decoration:none"> Zurück zu Ban Yaro</a></p>
<h1>Konto löschen</h1>
<p>Du kannst dein Ban Yaro-Konto und alle zugehörigen Daten dauerhaft löschen.</p>
<div class="warn">
Diese Aktion ist unwiderruflich. Alle Daten (Tagebuch, Gesundheit, Training, Fotos) werden dauerhaft gelöscht.
</div>
<p><strong>So löschst du dein Konto:</strong></p>
<ol>
<li>Öffne <a href="/" style="color:var(--c-primary)">banyaro.app</a> und melde dich an</li>
<li>Tippe auf das Menü-Symbol oben rechts</li>
<li>Gehe zu <strong>Einstellungen</strong></li>
<li>Scrolle nach unten zu <strong>Konto löschen"</strong></li>
<li>Bestätige die Löschung</li>
</ol>
<a href="/" class="btn">Ban Yaro öffnen</a>
<p style="margin-top:2rem;font-size:0.85rem">
Alternativ kannst du die Löschung per E-Mail an
<a href="mailto:support@banyaro.app" style="color:var(--c-primary)">support@banyaro.app</a> beantragen.
</p>
</body>
</html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "max-age=3600"})
# /force-update — SW + Cache-Killer für hartnäckige alte Versionen
# ------------------------------------------------------------------
@app.get("/force-update")
async def force_update():
from fastapi.responses import HTMLResponse
html = """<!DOCTYPE html><html><head><meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Ban Yaro Update</title>
<style>body{font-family:sans-serif;display:flex;align-items:center;justify-content:center;
height:100vh;margin:0;background:#0f1623;color:#fff;flex-direction:column;gap:16px}
p{color:#94a3b8;font-size:14px}</style></head>
<body>
<div> Aktualisiere Ban Yaro</div>
<p id="s">Service Worker wird entfernt</p>
<script>
// Zweiten Reload durch SW-updatefound verhindern
sessionStorage.setItem('by_skip_sw_reload','1');
// Fire-and-forget kein await, Reload nach spätestens 1.5s
try{
navigator.serviceWorker?.getRegistrations().then(r=>r.forEach(s=>s.unregister())).catch(()=>{});
caches.keys().then(k=>k.forEach(c=>caches.delete(c))).catch(()=>{});
}catch(e){}
setTimeout(()=>location.replace('/'),1500);
</script></body></html>"""
return HTMLResponse(content=html, headers={"Cache-Control": "no-store"})
# /partner — Influencer-Landingpage # /partner — Influencer-Landingpage
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@app.get("/partner") @app.get("/partner")

View file

@ -454,7 +454,21 @@ async def ki_zusammenfassung(dog_id: int, user=Depends(get_current_user)):
user_is_premium=bool(user.get("is_premium")), user_is_premium=bool(user.get("is_premium")),
user_id=user["id"], user_id=user["id"],
) )
return {"zusammenfassung": result} save_error = None
try:
with db() as conn:
conn.execute(
"INSERT INTO ki_health_reports (dog_id, user_id, bericht) VALUES (?,?,?)",
(dog_id, user["id"], result)
)
count = conn.execute(
"SELECT COUNT(*) FROM ki_health_reports WHERE dog_id=?", (dog_id,)
).fetchone()[0]
except Exception as e:
save_error = str(e)
count = 0
logger.warning(f"KI-Bericht konnte nicht gespeichert werden: {e}")
return {"zusammenfassung": result, "saved_count": count, "save_error": save_error}
except KIPremiumRequired as e: except KIPremiumRequired as e:
raise HTTPException(402, str(e)) raise HTTPException(402, str(e))
except KIUnavailableError as e: except KIUnavailableError as e:

View file

@ -232,6 +232,7 @@ async def ki_geburtstag(req: BirthdayRequest, request: Request,
try: try:
answer = await ki_module.complete( answer = await ki_module.complete(
system=system, prompt=prompt, max_tokens=600, requires_premium=False, system=system, prompt=prompt, max_tokens=600, requires_premium=False,
user_id=user["id"],
) )
with db() as conn: with db() as conn:
conn.execute( conn.execute(
@ -275,11 +276,11 @@ async def ki_rasse_erkennung(
# Rate-Limit prüfen # Rate-Limit prüfen
remaining_before = _check_rasse_limit(user["id"]) remaining_before = _check_rasse_limit(user["id"])
# Anthropic-Client holen (nutzt cached Instanz aus ki.py) # Anthropic-Key zur Laufzeit prüfen (nicht nur beim Modulstart)
if not ki_module.ANTHROPIC_KEY: import os as _os
api_key = _os.getenv("ANTHROPIC_KEY") or ki_module.ANTHROPIC_KEY
if not api_key:
raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.") raise HTTPException(503, "KI-Bildanalyse ist momentan nicht verfügbar.")
api_key = ki_module.ANTHROPIC_KEY
base64_data = base64.standard_b64encode(content).decode("utf-8") base64_data = base64.standard_b64encode(content).decode("utf-8")
prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n). prompt_text = """Analysiere dieses Bild und erkenne die Hunderasse(n).

View file

@ -142,3 +142,28 @@ async def put_world_config(body: WorldConfigIn, user=Depends(get_current_user)):
conn.execute("UPDATE users SET world_config=? WHERE id=?", conn.execute("UPDATE users SET world_config=? WHERE id=?",
(_json.dumps(body.config), user['id'])) (_json.dumps(body.config), user['id']))
return {"status": "ok"} return {"status": "ok"}
# ----------------------------------------------------------
# DELETE /profile/account — Konto unwiderruflich löschen
# ----------------------------------------------------------
@router.delete('/account')
async def delete_account(user=Depends(get_current_user)):
"""Löscht das Konto und alle zugehörigen Daten unwiderruflich."""
uid = user['id']
with db() as conn:
# Alle Hunde-IDs des Users
dog_ids = [r['id'] for r in conn.execute(
"SELECT id FROM dogs WHERE user_id=?", (uid,)).fetchall()]
for did in dog_ids:
conn.execute("DELETE FROM diary WHERE dog_id=?", (did,))
conn.execute("DELETE FROM health WHERE dog_id=?", (did,))
conn.execute("DELETE FROM training_sessions WHERE dog_id=?", (did,))
conn.execute("DELETE FROM training_streaks WHERE dog_id=?", (did,))
conn.execute("DELETE FROM expenses WHERE dog_id=?", (did,))
conn.execute("DELETE FROM dogs WHERE user_id=?", (uid,))
conn.execute("DELETE FROM push_subscriptions WHERE user_id=?", (uid,))
conn.execute("DELETE FROM notifications WHERE user_id=?", (uid,))
conn.execute("DELETE FROM forum_posts WHERE user_id=?", (uid,))
conn.execute("DELETE FROM users WHERE id=?", (uid,))
return {"status": "deleted"}

View file

@ -3087,13 +3087,15 @@ html.modal-open {
} }
@media (min-width: 768px) { @media (min-width: 768px) {
.map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; } .map-full-layout { top: 0; left: var(--nav-sidebar-width); bottom: 0; }
/* Zoom-Control und Filter-Tabs unter die Statusleiste schieben */
.map-full-layout .leaflet-top { padding-top: 28px; }
} }
.map-full { width: 100%; height: 100%; } .map-full { width: 100%; height: 100%; }
/* Legende: horizontaler Scroll-Strip oben */ /* Legende: horizontaler Scroll-Strip oben */
.map-legend { .map-legend {
position: absolute; position: absolute;
top: var(--space-2); top: 28px; /* mind. Status-Leisten-Höhe auf Tablet/iPad */
left: 42px; /* Zoom-Control (+/-) freilassen */ left: 42px; /* Zoom-Control (+/-) freilassen */
right: 0; right: 0;
z-index: 1000; z-index: 1000;

View file

@ -8,6 +8,7 @@
1. TOKENS Farben, Abstände, Typografie, Schatten 1. TOKENS Farben, Abstände, Typografie, Schatten
------------------------------------------------------------ */ ------------------------------------------------------------ */
:root { :root {
color-scheme: dark light;
/* Primärfarben — Honig-Amber aus Ban Yaros Fell */ /* Primärfarben — Honig-Amber aus Ban Yaros Fell */
--c-primary: #C4843A; --c-primary: #C4843A;
--c-primary-dark: #9E6520; --c-primary-dark: #9E6520;

View file

@ -3,7 +3,7 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover"> <meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
<meta name="theme-color" content="#C4843A"> <meta name="theme-color" content="#0f1623" id="meta-theme-color">
<meta name="description" content="Ban Yaro — Die kostenlose Hunde-App für Deutschland. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. DSGVO-konform, ohne App Store."> <meta name="description" content="Ban Yaro — Die kostenlose Hunde-App für Deutschland. Tagebuch, Impfpass, Giftköder-Alarm, Gassi-Community, Hundesitting. DSGVO-konform, ohne App Store.">
<meta name="keywords" content="Hunde App, Hunde Tagebuch, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundesitting, Hunde Wiki, Hunderassen, PWA Hunde, DSGVO Hunde App"> <meta name="keywords" content="Hunde App, Hunde Tagebuch, Impfpass Hund, Giftköder Alarm, Gassi Community, Hundesitting, Hunde Wiki, Hunderassen, PWA Hunde, DSGVO Hunde App">
<link rel="canonical" href="https://banyaro.app/"> <link rel="canonical" href="https://banyaro.app/">
@ -67,6 +67,9 @@
} }
</script> </script>
<!-- Eigenes Farbschema — verhindert Browser-eigenen Dark-Mode-Filter -->
<meta name="color-scheme" content="dark light">
<!-- Favicons --> <!-- Favicons -->
<link rel="icon" type="image/x-icon" href="/favicon.ico"> <link rel="icon" type="image/x-icon" href="/favicon.ico">
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png"> <link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png">
@ -82,20 +85,25 @@
<title>Ban Yaro</title> <title>Ban Yaro</title>
<!-- Theme vor CSS setzen — verhindert Flash of unstyled content --> <!-- Theme + theme-color Statusleiste vor CSS setzen -->
<script> <script>
(function() { (function() {
var t = localStorage.getItem('by_theme'); var t = localStorage.getItem('by_theme');
var isDark = t === 'dark' || (t !== 'light' && window.matchMedia('(prefers-color-scheme: dark)').matches);
var isAndroid = /android/i.test(navigator.userAgent);
if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark'); if (t === 'dark') document.documentElement.setAttribute('data-theme', 'dark');
if (t === 'light') document.documentElement.setAttribute('data-theme', 'light'); if (t === 'light') document.documentElement.setAttribute('data-theme', 'light');
// 'system' (oder kein Wert) → kein data-theme → @media prefers-color-scheme greift // Android: immer dunkel (Amber-Streifen nicht möglich transparent zu machen)
// iOS: black-translucent übernimmt das
var m = document.getElementById('meta-theme-color');
if (m) m.setAttribute('content', (isDark || isAndroid) ? '#0f1623' : '#C4843A');
})(); })();
</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=709"> <link rel="stylesheet" href="/css/design-system.css?v=826">
<link rel="stylesheet" href="/css/layout.css?v=709"> <link rel="stylesheet" href="/css/layout.css?v=826">
<link rel="stylesheet" href="/css/components.css?v=738"> <link rel="stylesheet" href="/css/components.css?v=826">
</head> </head>
<body> <body>
@ -575,10 +583,10 @@
<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=785"></script> <script src="/js/api.js?v=826"></script>
<script src="/js/ui.js?v=785"></script> <script src="/js/ui.js?v=826"></script>
<script src="/js/app.js?v=785"></script> <script src="/js/app.js?v=826"></script>
<script src="/js/worlds.js?v=785"></script> <script src="/js/worlds.js?v=826"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->
@ -625,20 +633,36 @@
window.addEventListener('load', () => { window.addEventListener('load', () => {
navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' }) navigator.serviceWorker.register('/sw.js', { updateViaCache: 'none' })
.then(reg => { .then(reg => {
// iOS PWA: Update sofort prüfen (Standalone-Modus prüft sonst nicht automatisch) function _watchSW(sw) {
if (!sw) return;
sw.addEventListener('statechange', () => {
if (sw.state === 'activated') {
// Kein zweiter Reload nach force-update
if (sessionStorage.getItem('by_skip_sw_reload')) {
sessionStorage.removeItem('by_skip_sw_reload');
return;
}
window.location.replace('/?_t=' + Date.now());
}
});
}
// Listener VOR update() registrieren — verhindert Race Condition
reg.addEventListener('updatefound', () => _watchSW(reg.installing));
// Falls SW bereits installiert (Seite wurde nach SW-Install neu geladen)
if (reg.installing) _watchSW(reg.installing);
reg.update(); reg.update();
}) })
.catch(err => console.warn('SW Registration failed:', err)); .catch(err => console.warn('SW Registration failed:', err));
}); });
// iOS PWA: erneut prüfen wenn App aus dem Hintergrund kommt // Backup: erneut prüfen wenn App aus dem Hintergrund kommt
document.addEventListener('visibilitychange', () => { document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') { if (document.visibilityState === 'visible') {
navigator.serviceWorker.getRegistration().then(reg => reg?.update()); navigator.serviceWorker.getRegistration().then(reg => reg?.update());
} }
}); });
// Wenn neuer SW die Kontrolle übernimmt → Seite neu laden // Backup: controllerchange (falls updatefound nicht feuert)
navigator.serviceWorker.addEventListener('controllerchange', () => { navigator.serviceWorker.addEventListener('controllerchange', () => {
window.location.replace('/?_t=' + Date.now()); window.location.replace('/?_t=' + Date.now());
}); });

View file

@ -45,6 +45,13 @@ const API = (() => {
throw new APIError(msg, 0, 'network'); throw new APIError(msg, 0, 'network');
} }
// Versions-Check: Server meldet neue Version → Banner anzeigen (einmalig)
const serverVer = response.headers.get('x-app-version');
if (serverVer && serverVer !== APP_VER && !window._byUpdatePending) {
window._byUpdatePending = true;
window._byNewVersion = serverVer;
}
if (response.status === 204) return null; if (response.status === 204) return null;
let data; let data;

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '785'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '826'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.5.0'; // ← semantische Version, wird bei make release gesetzt
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
@ -119,6 +119,18 @@ const App = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
function navigate(pageId, pushHistory = true, params = {}) { function navigate(pageId, pushHistory = true, params = {}) {
if (!pages[pageId]) return; if (!pages[pageId]) return;
// Neue Version erkannt → nur aktualisieren wenn kein Bearbeitungsfenster offen ist
if (window._byUpdatePending) {
const modalOpen = document.querySelector('#modal-container .modal-overlay') !== null;
if (!modalOpen) {
window._byUpdatePending = false;
sessionStorage.setItem('by_updated_to', window._byNewVersion || '');
sessionStorage.setItem('by_update_target', pageId); // Zielseite nach Update
location.href = '/force-update';
return;
}
// Modal offen → beim nächsten Seitenwechsel versuchen
}
if (window.Worlds?._visible) window.Worlds.hide(); if (window.Worlds?._visible) window.Worlds.hide();
// Aktive Seite ausblenden // Aktive Seite ausblenden
@ -536,6 +548,7 @@ const App = (() => {
if (window.Worlds) window.Worlds.init(state); if (window.Worlds) window.Worlds.init(state);
_showVerifyBanner(); _showVerifyBanner();
_showAndroidBetaBanner();
_updateNotifBadge(); _updateNotifBadge();
_updateChatBadge(); _updateChatBadge();
_checkNearbyAlerts(); _checkNearbyAlerts();
@ -612,11 +625,34 @@ const App = (() => {
function _applyUserTheme(user) { function _applyUserTheme(user) {
const theme = user?.preferred_theme; const theme = user?.preferred_theme;
if (!theme || theme === 'system') return; // System-Einstellung: nichts tun if (!theme || theme === 'system') { _syncThemeColor(); return; }
localStorage.setItem('by_theme', theme); localStorage.setItem('by_theme', theme);
const html = document.documentElement; const html = document.documentElement;
if (theme === 'dark') html.setAttribute('data-theme', 'dark'); if (theme === 'dark') html.setAttribute('data-theme', 'dark');
else if (theme === 'light') html.setAttribute('data-theme', 'light'); else if (theme === 'light') html.setAttribute('data-theme', 'light');
_syncThemeColor();
}
function _syncThemeColor() {
const isAndroid = /android/i.test(navigator.userAgent);
const isDark = isAndroid
|| document.documentElement.getAttribute('data-theme') === 'dark'
|| (window.matchMedia('(prefers-color-scheme: dark)').matches
&& document.documentElement.getAttribute('data-theme') !== 'light');
document.getElementById('meta-theme-color')?.setAttribute('content', isDark ? '#0f1623' : '#C4843A');
}
function _showAndroidBetaBanner() {
// Nur auf Android, nur einmalig, nur für eingeloggte Nutzer
if (!/android/i.test(navigator.userAgent)) return;
if (localStorage.getItem('by_android_beta_dismissed')) return;
setTimeout(() => {
UI.toast.info(
'📱 Play Store Beta: Hilf uns beim Android-Test! Schreib an <a href="mailto:support@banyaro.app" style="color:#fff;font-weight:700;text-decoration:underline">support@banyaro.app</a>',
20000
);
localStorage.setItem('by_android_beta_dismissed', '1');
}, 5000);
} }
function _showVerifyBanner() { function _showVerifyBanner() {
@ -857,6 +893,7 @@ const App = (() => {
// INITIALISIERUNG // INITIALISIERUNG
// ---------------------------------------------------------- // ----------------------------------------------------------
async function init() { async function init() {
_syncThemeColor(); // Statusleisten-Farbe sofort setzen
// Spezielle Hash-Parameter → in App bleiben (kein /info-Redirect) // Spezielle Hash-Parameter → in App bleiben (kein /info-Redirect)
const _rawHash = location.hash.replace('#', ''); const _rawHash = location.hash.replace('#', '');
const _hashQuery = _rawHash.split('?')[1] || ''; const _hashQuery = _rawHash.split('?')[1] || '';
@ -876,6 +913,18 @@ const App = (() => {
_bindNavigation(); _bindNavigation();
// Nach stillem Update: Toast + zur ursprünglichen Zielseite navigieren
const updatedTo = sessionStorage.getItem('by_updated_to');
if (updatedTo) {
sessionStorage.removeItem('by_updated_to');
const target = sessionStorage.getItem('by_update_target');
sessionStorage.removeItem('by_update_target');
setTimeout(() => {
UI.toast?.success(`App auf v${updatedTo} aktualisiert`);
if (target && pages[target]) navigate(target, false);
}, 800);
}
try { localStorage.removeItem('by_wissen_open'); } catch (_) {} try { localStorage.removeItem('by_wissen_open'); } catch (_) {}
_initVersionCheck(); _initVersionCheck();
@ -977,122 +1026,8 @@ const App = (() => {
} }
// ---------------------------------------------------------- // ----------------------------------------------------------
// ---------------------------------------------------------- // VERSION-CHECK — stilles Auto-Update beim nächsten Seitenwechsel
// VERSION-CHECK — persistentes Banner wenn neue Version verfügbar function _initVersionCheck() { /* X-App-Version Header in api.js übernimmt das */ }
// ----------------------------------------------------------
let _updateBannerShown = false;
async function _checkVersion() {
try {
const r = await fetch('/api/version', { cache: 'no-store' });
if (!r.ok) return;
const { version } = await r.json();
if (version && version !== APP_VER && !_updateBannerShown) {
_updateBannerShown = true;
_showUpdateBanner(version);
}
} catch { /* offline — ignorieren */ }
}
function _showUpdateBanner(newVersion) {
const isIos = /iphone|ipad|ipod/i.test(navigator.userAgent);
const existing = document.getElementById('app-update-banner');
if (existing) return;
const banner = document.createElement('div');
banner.id = 'app-update-banner';
banner.style.cssText = [
'position:fixed;bottom:calc(env(safe-area-inset-bottom,0px) + 72px);left:12px;right:12px',
'z-index:9000;background:var(--c-primary);color:#fff;border-radius:16px',
'padding:14px 16px;box-shadow:0 4px 20px rgba(0,0,0,0.3)',
'display:flex;flex-direction:column;gap:10px',
].join(';');
banner.innerHTML = `
<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">
<div>
<div style="font-weight:700;font-size:var(--text-sm)">
Neue Version verfügbar (v${newVersion})
</div>
<div style="font-size:var(--text-xs);opacity:0.85;margin-top:2px">
Tippe auf Aktualisieren um die neueste Version zu laden.
</div>
</div>
<div style="display:flex;gap:8px;flex-shrink:0">
<button id="upd-btn-reload"
style="background:rgba(255,255,255,0.2);border:1px solid rgba(255,255,255,0.4);
color:#fff;border-radius:10px;padding:8px 14px;cursor:pointer;
font-size:var(--text-sm);font-weight:700;white-space:nowrap">
Aktualisieren
</button>
<button id="upd-btn-close"
style="background:none;border:none;color:rgba(255,255,255,0.7);
cursor:pointer;font-size:1.1rem;padding:4px 6px;line-height:1"></button>
</div>
</div>
<div id="upd-ios-hint" style="display:none;font-size:var(--text-xs);
background:rgba(0,0,0,0.2);border-radius:10px;padding:10px 12px;line-height:1.6">
${isIos
? `Falls die App nach dem Aktualisieren noch die alte Version zeigt:<br>
<strong>1.</strong> Drücke lange auf das App-Icon am Homescreen<br>
<strong>2.</strong> Wähle App entfernen" (nur das Symbol, keine Daten)<br>
<strong>3.</strong> Öffne banyaro.app in Safari und füge die App erneut hinzu`
: `Falls die Seite noch die alte Version zeigt:<br>
Drücke <strong>Cmd+Shift+R</strong> (Mac) bzw. <strong>Ctrl+Shift+R</strong> (Windows/Android Chrome) für einen harten Reload.`}
</div>
`;
document.body.appendChild(banner);
banner.querySelector('#upd-btn-close').addEventListener('click', () => banner.remove());
banner.querySelector('#upd-btn-reload').addEventListener('click', async () => {
const btn = banner.querySelector('#upd-btn-reload');
btn.textContent = 'Lädt…';
btn.disabled = true;
sessionStorage.setItem('by_update_reload', APP_VER);
// ?_t= Timestamp zwingt iOS bfcache zur Aufgabe — wird beim Start sofort entfernt
setTimeout(() => location.replace('/?_t=' + Date.now()), 800);
try {
const reg = await navigator.serviceWorker?.getRegistration();
if (reg?.waiting) reg.waiting.postMessage({ type: 'SKIP_WAITING' });
reg?.update().catch(() => {}); // kein await — kann hängen
const keys = await caches.keys();
await Promise.all(keys.map(k => caches.delete(k)));
} catch { /* ignorieren */ }
});
}
function _initVersionCheck() {
// Beim Start nach 10 Sekunden prüfen (nicht sofort — Prio für Auth)
setTimeout(_checkVersion, 10_000);
// Dann alle 30 Minuten
setInterval(_checkVersion, 30 * 60_000);
// Beim Wiedereinstieg in die App
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible') _checkVersion();
});
// Nach Reload: war das ein Update-Reload? Falls Version immer noch alt → iOS-Hinweis
const reloadVer = sessionStorage.getItem('by_update_reload');
if (reloadVer && reloadVer === APP_VER) {
// Version hat sich nicht geändert nach Reload → iOS-Cache-Problem
sessionStorage.removeItem('by_update_reload');
setTimeout(() => {
fetch('/api/version', { cache: 'no-store' })
.then(r => r.json())
.then(({ version }) => {
if (version && version !== APP_VER) {
_updateBannerShown = true;
_showUpdateBanner(version);
// iOS-Hinweis sofort aufklappen
setTimeout(() => {
document.getElementById('upd-ios-hint')?.style.setProperty('display', 'block');
}, 300);
}
}).catch(() => {});
}, 2000);
}
}
// ---------------------------------------------------------- // ----------------------------------------------------------
// ÖFFENTLICHE API // ÖFFENTLICHE API

View file

@ -2312,11 +2312,14 @@ window.Page_health = (() => {
// ---------------------------------------------------------- // ----------------------------------------------------------
// KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte) // KI-GESUNDHEITSBERICHTE (gespeicherte automatische Berichte)
// ---------------------------------------------------------- // ----------------------------------------------------------
async function _loadKiBerichte(dogId) { async function _loadKiBerichte(dogId, force = false) {
const el = _container.querySelector('#health-ki-berichte'); const el = _container.querySelector('#health-ki-berichte');
if (!el) return; if (!el) return;
try { try {
const berichte = await API.health.kiBerichte(dogId); // force=true: Cache-Buster damit SW den neuen Bericht nicht übersieht
const berichte = force
? await API.get(`/dogs/${dogId}/health/ki-berichte?_t=${Date.now()}`)
: await API.health.kiBerichte(dogId);
if (!berichte || berichte.length === 0) return; if (!berichte || berichte.length === 0) return;
const neuester = berichte[0]; const neuester = berichte[0];
const datum = neuester.erstellt_at const datum = neuester.erstellt_at
@ -2343,19 +2346,34 @@ window.Page_health = (() => {
${berichte.length > 1 ? `<div style="font-size:var(--text-xs);color:var(--c-accent,#c4843a);margin-top:var(--space-1)">${berichte.length} Berichte gespeichert — zum Öffnen tippen</div>` : ''} ${berichte.length > 1 ? `<div style="font-size:var(--text-xs);color:var(--c-accent,#c4843a);margin-top:var(--space-1)">${berichte.length} Berichte gespeichert — zum Öffnen tippen</div>` : ''}
</div>`; </div>`;
el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => { el.querySelector('.health-ki-bericht-banner').addEventListener('click', () => {
const listeHtml = berichte.map((b, i) => { let idx = 0;
const d = b.erstellt_at const fmtDate = b => b.erstellt_at
? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' }) ? new Date(b.erstellt_at).toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
: ''; : '';
return `<div style="${i > 0 ? 'border-top:1px solid var(--c-border);padding-top:var(--space-3);margin-top:var(--space-3)' : ''}">
${d ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-bottom:var(--space-1)">${d}</div>` : ''} function showBericht() {
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div> const b = berichte[idx];
</div>`; const nav = berichte.length > 1 ? `
}).join(''); <div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<button onclick="window._kiPrev()" style="padding:6px 16px;border-radius:999px;
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
font-size:var(--text-sm);${idx >= berichte.length-1 ? 'opacity:.3;pointer-events:none' : ''}"> Älter</button>
<span style="font-size:var(--text-xs);color:var(--c-text-muted)">${idx+1} / ${berichte.length}</span>
<button onclick="window._kiNext()" style="padding:6px 16px;border-radius:999px;
border:1.5px solid var(--c-border);background:var(--c-surface);cursor:pointer;
font-size:var(--text-sm);${idx <= 0 ? 'opacity:.3;pointer-events:none' : ''}">Neuer </button>
</div>` : '';
UI.modal.open({ UI.modal.open({
title: `${UI.icon('star')} KI-Gesundheitsberichte`, title: `${UI.icon('star')} KI-Gesundheitsberichte`,
body: listeHtml, body: `${nav}
<div style="font-size:var(--text-xs);color:var(--c-text-muted);text-align:center;margin-bottom:8px">${fmtDate(b)}</div>
<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(b.bericht)}</div>`,
}); });
}
window._kiPrev = () => { if (idx < berichte.length - 1) { idx++; showBericht(); } };
window._kiNext = () => { if (idx > 0) { idx--; showBericht(); } };
showBericht();
}); });
} catch (_) { } catch (_) {
// Silently ignore — Berichte sind optional // Silently ignore — Berichte sind optional
@ -2790,11 +2808,16 @@ window.Page_health = (() => {
UI.setLoading(btn, true); UI.setLoading(btn, true);
try { try {
const { zusammenfassung } = await API.health.kiZusammenfassung(_appState.activeDog.id); const res = await API.health.kiZusammenfassung(_appState.activeDog.id);
const zusammenfassung = res.zusammenfassung ?? res;
if (res.save_error) UI.toast.warning(`Speichern fehlgeschlagen: ${res.save_error}`);
else if (res.saved_count !== undefined) UI.toast.info(`${res.saved_count} Bericht(e) in DB`, { duration: 8000 });
UI.modal.open({ UI.modal.open({
title: `${UI.icon('star')} KI-Gesundheitsbericht`, title: `${UI.icon('star')} KI-Gesundheitsbericht`,
body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`, body: `<div style="white-space:pre-wrap;line-height:1.7;font-size:var(--text-sm)">${_esc(zusammenfassung)}</div>`,
}); });
// Berichte-Liste nach Generierung frisch laden (Cache-Buster)
_loadKiBerichte(_appState.activeDog.id, true);
} catch (err) { } catch (err) {
if (err.status === 503) { if (err.status === 503) {
UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.'); UI.toast.error('KI ist momentan nicht verfügbar. Bitte später erneut versuchen.');

View file

@ -287,6 +287,15 @@ window.Page_settings = (() => {
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg> <svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#sign-out"></use></svg>
Abmelden Abmelden
</button> </button>
<button id="settings-delete-account-btn"
style="width:100%;margin-top:var(--space-2);display:flex;align-items:center;justify-content:center;
gap:var(--space-2);padding:var(--space-2) var(--space-4);
border-radius:var(--radius-md);border:none;
background:none;color:var(--c-text-muted);
font-size:var(--text-xs);cursor:pointer">
<svg class="ph-icon" aria-hidden="true" style="width:12px;height:12px"><use href="/icons/phosphor.svg#trash"></use></svg>
Konto löschen
</button>
</div> </div>
</div> </div>
</div> </div>
@ -320,6 +329,15 @@ window.Page_settings = (() => {
<option value="dark" ${(u.preferred_theme||localStorage.getItem('by_theme')) === 'dark' ? 'selected' : ''}>Dunkel</option> <option value="dark" ${(u.preferred_theme||localStorage.getItem('by_theme')) === 'dark' ? 'selected' : ''}>Dunkel</option>
</select> </select>
</div> </div>
${/SamsungBrowser/i.test(navigator.userAgent) ? `
<div style="margin:6px 0 4px;padding:10px 12px;border-radius:var(--radius-md);
background:var(--c-warning-subtle,rgba(245,158,11,0.12));
border:1px solid rgba(245,158,11,0.3);
font-size:var(--text-xs);color:var(--c-text-secondary);line-height:1.5">
<strong style="color:var(--c-warning,#f59e0b)">Samsung Internet Tipps:</strong><br>
Farben: <em>Einstellungen Webseitenansicht Dark Mode</em> deaktivieren.<br>
Vollbild: <em>Einstellungen Display Navigationsleiste Wischgesten</em> aktivieren.
</div>` : ''}
<!-- KI-Notiz-Assistent --> <!-- KI-Notiz-Assistent -->
<div class="settings-toggle-row"> <div class="settings-toggle-row">
@ -789,6 +807,24 @@ window.Page_settings = (() => {
_render(); _render();
}); });
document.getElementById('settings-delete-account-btn')?.addEventListener('click', async () => {
const ok = await UI.modal.confirm({
title: 'Konto unwiderruflich löschen?',
body: 'Alle deine Daten (Tagebuch, Gesundheit, Training, Fotos) werden dauerhaft gelöscht. Diese Aktion kann nicht rückgängig gemacht werden.',
confirmText: 'Ja, Konto löschen',
danger: true,
});
if (!ok) return;
try {
await API.del('/profile/account');
_appState.user = null; _appState.dogs = []; _appState.activeDog = null;
UI.toast.info('Dein Konto wurde gelöscht.');
App.navigate('welcome');
} catch {
UI.toast.error('Konto konnte nicht gelöscht werden. Bitte versuche es erneut.');
}
});
document.getElementById('settings-install-btn')?.addEventListener('click', () => { document.getElementById('settings-install-btn')?.addEventListener('click', () => {
App.navigate('welcome', true, { install: true }); App.navigate('welcome', true, { install: true });
}); });

View file

@ -479,6 +479,7 @@ window.Page_uebungen = (() => {
_render(); _render();
_helpHandle = UI.pageInfo(_container, { _helpHandle = UI.pageInfo(_container, {
pageId: 'uebungen', pageId: 'uebungen',
defaultClosed: true,
title: 'Übungsbibliothek', title: 'Übungsbibliothek',
icon: 'graduation-cap', icon: 'graduation-cap',
intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.', intro: 'Hier findest du alle Übungen für deinen Hund — von Grundkommandos bis zu Tricks und Problemverhalten. Du kannst deinen Trainingsfortschritt für jede Übung festhalten.',

View file

@ -316,8 +316,8 @@ const UI = (() => {
// Kein automatischer absolut-positionierter Trigger mehr. // Kein automatischer absolut-positionierter Trigger mehr.
// Aufrufer kann openModal() nutzen und den Button selbst platzieren. // Aufrufer kann openModal() nutzen und den Button selbst platzieren.
// Banner beim ersten Besuch // Banner beim ersten Besuch (nicht wenn defaultClosed gesetzt)
if (!seen) { if (!seen && !config.defaultClosed) {
localStorage.setItem(seenKey, '1'); localStorage.setItem(seenKey, '1');
const banner = document.createElement('div'); const banner = document.createElement('div');
banner.className = 'pinfo-banner'; banner.className = 'pinfo-banner';

View file

@ -741,7 +741,7 @@ window.Worlds = (() => {
border-radius:16px;padding:10px 4px 8px;height:80px;box-sizing:border-box; border-radius:16px;padding:10px 4px 8px;height:80px;box-sizing:border-box;
display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px; display:flex;flex-direction:column;align-items:center;justify-content:center;gap:5px;
cursor:grab;position:relative;min-width:0;overflow:hidden; cursor:grab;position:relative;min-width:0;overflow:hidden;
user-select:none;-webkit-tap-highlight-color:transparent;touch-action:none"> user-select:none;-webkit-tap-highlight-color:transparent;touch-action:pan-y">
${!c.pinned ? ` ${!c.pinned ? `
<button class="wc-remove" data-page="${c.page}" data-zone="${w}" <button class="wc-remove" data-page="${c.page}" data-zone="${w}"
style="position:absolute;top:-10px;right:-10px;width:30px;height:30px; style="position:absolute;top:-10px;right:-10px;width:30px;height:30px;

View file

@ -10,8 +10,8 @@
"display_override": ["window-controls-overlay", "standalone"], "display_override": ["window-controls-overlay", "standalone"],
"launch_handler": { "client_mode": "focus-existing" }, "launch_handler": { "client_mode": "focus-existing" },
"orientation": "portrait-primary", "orientation": "portrait-primary",
"background_color": "#FAF7F2", "background_color": "#0f1623",
"theme_color": "#C4843A", "theme_color": "#0f1623",
"lang": "de", "lang": "de",
"dir": "ltr", "dir": "ltr",
"categories": [ "categories": [

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v785'; const CACHE_VERSION = 'by-v826';
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
@ -120,7 +120,7 @@ const _CACHEABLE_GET = [
/^\/api\/dogs(\/\d+)?$/, /^\/api\/dogs(\/\d+)?$/,
/^\/api\/dogs\/\d+\/welcome-dashboard/, /^\/api\/dogs\/\d+\/welcome-dashboard/,
/^\/api\/dogs\/\d+\/diary/, /^\/api\/dogs\/\d+\/diary/,
/^\/api\/dogs\/\d+\/health/, /^\/api\/dogs\/\d+\/health(?!\/ki-)/, // ki-berichte + ki-zusammenfassung nie cachen
/^\/api\/training\/exercises/, /^\/api\/training\/exercises/,
/^\/api\/training\/progress/, /^\/api\/training\/progress/,
/^\/api\/wiki\/rassen/, /^\/api\/wiki\/rassen/,
@ -188,6 +188,12 @@ self.addEventListener('activate', event => {
self.addEventListener('fetch', event => { self.addEventListener('fetch', event => {
const url = new URL(event.request.url); const url = new URL(event.request.url);
// Force-Update: SW komplett umgehen → direkt vom Netzwerk
if (url.searchParams.has('_nocache')) {
event.respondWith(fetch(event.request.url.replace(/[?&]_nocache=[^&]*/,'') || '/', { cache: 'no-store' }));
return;
}
// API-Calls mit Timeout, Caching und Write-Queue // API-Calls mit Timeout, Caching und Write-Queue
if (url.pathname.startsWith('/api/')) { if (url.pathname.startsWith('/api/')) {
const method = event.request.method; const method = event.request.method;
@ -224,6 +230,10 @@ self.addEventListener('fetch', event => {
return; return;
} }
// Media-Uploads + KI-Endpoints: direkt ans Netzwerk — kein Clone, kein Timeout, kein Queue
// KI-Anfragen ans lokale LLM können mehrere Minuten dauern
if (method === 'POST' && (_isMediaUpload(event.request) || url.pathname.startsWith('/api/ki/') || url.pathname.includes('/health/ki-') || url.pathname.includes('/health/symptom'))) return;
// Mutationen (POST/PATCH/PUT/DELETE): mit Timeout, bei Offline → Queue // Mutationen (POST/PATCH/PUT/DELETE): mit Timeout, bei Offline → Queue
if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) { if (['POST', 'PATCH', 'PUT', 'DELETE'].includes(method)) {
event.respondWith((async () => { event.respondWith((async () => {