Feature: Hundeernährungs-Feature — Kalorien-Rechner, Futter-Guide, Giftliste, KI-Berater (SW by-v698)
This commit is contained in:
parent
b1d9fb4f54
commit
6e4bf25581
7 changed files with 838 additions and 8 deletions
|
|
@ -1951,6 +1951,21 @@ def _migrate(conn_factory):
|
|||
conn.execute("ALTER TABLE users ADD COLUMN gassi_stunde_push INTEGER NOT NULL DEFAULT 0")
|
||||
logger.info("Migration: users.gassi_stunde_push bereit.")
|
||||
|
||||
# Futter-Profil
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS futter_profil (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
dog_id INTEGER REFERENCES dogs(id) ON DELETE CASCADE UNIQUE,
|
||||
futter_typ TEXT,
|
||||
marke TEXT,
|
||||
kcal_tag INTEGER,
|
||||
portionen INTEGER DEFAULT 2,
|
||||
notizen TEXT,
|
||||
updated_at TEXT NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
""")
|
||||
logger.info("Migration: futter_profil bereit.")
|
||||
|
||||
# Wiederkehrende Ausgaben (Daueraufträge)
|
||||
conn.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS recurring_expenses (
|
||||
|
|
|
|||
|
|
@ -6,9 +6,10 @@ import os
|
|||
import html
|
||||
import logging
|
||||
from collections import deque
|
||||
import httpx
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import FileResponse, JSONResponse
|
||||
from fastapi.responses import FileResponse, JSONResponse, Response
|
||||
from starlette.middleware.base import BaseHTTPMiddleware
|
||||
from fastapi.middleware.gzip import GZipMiddleware
|
||||
from brotli_asgi import BrotliMiddleware
|
||||
|
|
@ -43,10 +44,43 @@ logger = logging.getLogger(__name__)
|
|||
# ------------------------------------------------------------------
|
||||
# Startup / Shutdown
|
||||
# ------------------------------------------------------------------
|
||||
def _backfill_image_sizes():
|
||||
"""Füllt img_width/img_height für alle diary_media-Bilder ohne Maße nach."""
|
||||
import io
|
||||
from database import db
|
||||
from media_utils import get_image_size
|
||||
MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
||||
with db() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT id, url FROM diary_media WHERE media_type='image' AND img_width IS NULL"
|
||||
).fetchall()
|
||||
if not rows:
|
||||
return
|
||||
logger.info("Backfill Bildmaße: %d Einträge...", len(rows))
|
||||
updated = 0
|
||||
for row in rows:
|
||||
# url ist z.B. /media/diary/xxx.jpg → Pfad: MEDIA_DIR/diary/xxx.jpg
|
||||
rel = row["url"].removeprefix("/media/")
|
||||
path = os.path.join(MEDIA_DIR, rel)
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
data = f.read()
|
||||
size = get_image_size(data)
|
||||
if size:
|
||||
with db() as conn:
|
||||
conn.execute(
|
||||
"UPDATE diary_media SET img_width=?, img_height=? WHERE id=?",
|
||||
(size[0], size[1], row["id"])
|
||||
)
|
||||
updated += 1
|
||||
except Exception:
|
||||
pass
|
||||
logger.info("Backfill Bildmaße abgeschlossen: %d/%d aktualisiert.", updated, len(rows))
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
logger.info("Ban Yaro startet...")
|
||||
init_db()
|
||||
_backfill_image_sizes()
|
||||
from routes.movies import seed_movies
|
||||
seed_movies()
|
||||
logger.info(f"KI-Modus: {ki.KI_MODE}")
|
||||
|
|
@ -76,7 +110,7 @@ class SecurityHeadersMiddleware(BaseHTTPMiddleware):
|
|||
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains"
|
||||
response.headers["Content-Security-Policy"] = (
|
||||
"default-src 'self'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval'; "
|
||||
"script-src 'self' 'unsafe-inline' 'unsafe-eval' https://umami.motocamp.de; "
|
||||
"style-src 'self' 'unsafe-inline'; "
|
||||
"img-src 'self' data: blob: https:; "
|
||||
"connect-src 'self' https:; "
|
||||
|
|
@ -198,6 +232,7 @@ from routes.adoption import router as adoption_router
|
|||
from routes.health_docs import router as health_docs_router
|
||||
from routes.passport import router as passport_router
|
||||
from routes.playdate import router as playdate_router
|
||||
from routes.ernaehrung import router as ernaehrung_router
|
||||
|
||||
app.include_router(auth_router, prefix="/api/auth", tags=["Auth"])
|
||||
app.include_router(dogs_router, prefix="/api/dogs", tags=["Hunde"])
|
||||
|
|
@ -256,6 +291,7 @@ app.include_router(adoption_router, prefix="/api/adoption", ta
|
|||
app.include_router(health_docs_router, prefix="/api/health-docs", tags=["Gesundheitsdokumente"])
|
||||
app.include_router(passport_router, prefix="/api/passport", tags=["Hundepass"])
|
||||
app.include_router(playdate_router, prefix="/api/playdate", tags=["Playdate"])
|
||||
app.include_router(ernaehrung_router, prefix="/api/dogs", tags=["Ernährung"])
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
|
@ -285,6 +321,27 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media")
|
|||
os.makedirs(MEDIA_DIR, exist_ok=True)
|
||||
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
|
||||
|
||||
@app.get("/stats/script.js")
|
||||
async def umami_script_proxy():
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.get("https://umami.motocamp.de/script.js")
|
||||
return Response(content=r.content, media_type="application/javascript",
|
||||
headers={"Cache-Control": "public, max-age=86400"})
|
||||
|
||||
@app.post("/stats/api/send")
|
||||
async def umami_send_proxy(request: Request):
|
||||
body = await request.body()
|
||||
async with httpx.AsyncClient(timeout=10) as client:
|
||||
r = await client.post(
|
||||
"https://umami.motocamp.de/api/send",
|
||||
content=body,
|
||||
headers={"Content-Type": "application/json",
|
||||
"User-Agent": request.headers.get("user-agent", "")},
|
||||
)
|
||||
return Response(content=r.content, status_code=r.status_code,
|
||||
media_type="application/json")
|
||||
|
||||
|
||||
@app.get("/robots.txt")
|
||||
async def robots():
|
||||
return FileResponse(f"{STATIC_DIR}/robots.txt", media_type="text/plain")
|
||||
|
|
|
|||
145
backend/routes/ernaehrung.py
Normal file
145
backend/routes/ernaehrung.py
Normal file
|
|
@ -0,0 +1,145 @@
|
|||
"""BAN YARO — Ernährungs-Routes"""
|
||||
|
||||
import logging
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from pydantic import BaseModel
|
||||
from typing import Optional
|
||||
from database import db
|
||||
from auth import get_current_user
|
||||
import ki as ki_module
|
||||
|
||||
router = APIRouter()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Schemas
|
||||
# ------------------------------------------------------------------
|
||||
class FutterProfilUpdate(BaseModel):
|
||||
futter_typ: Optional[str] = None # trocken|nass|barf|mix
|
||||
marke: Optional[str] = None
|
||||
kcal_tag: Optional[int] = None
|
||||
portionen: Optional[int] = None
|
||||
notizen: Optional[str] = None
|
||||
|
||||
|
||||
class KiBeratungRequest(BaseModel):
|
||||
frage: str
|
||||
dog_name: Optional[str] = None
|
||||
rasse: Optional[str] = None
|
||||
alter: Optional[str] = None
|
||||
gewicht: Optional[float] = None
|
||||
aktiv: Optional[bool] = None
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Hilfsfunktion: Zugriffsprüfung
|
||||
# ------------------------------------------------------------------
|
||||
def _check_dog_access(conn, dog_id: int, user_id: int):
|
||||
row = conn.execute(
|
||||
"SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user_id)
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Hund nicht gefunden.")
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# GET /dogs/{dog_id}/ernaehrung
|
||||
# ------------------------------------------------------------------
|
||||
@router.get("/{dog_id}/ernaehrung")
|
||||
async def get_ernaehrung(dog_id: int, user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_check_dog_access(conn, dog_id, user["id"])
|
||||
row = conn.execute(
|
||||
"SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
|
||||
).fetchone()
|
||||
if not row:
|
||||
return {}
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# PUT /dogs/{dog_id}/ernaehrung
|
||||
# ------------------------------------------------------------------
|
||||
@router.put("/{dog_id}/ernaehrung")
|
||||
async def put_ernaehrung(dog_id: int, body: FutterProfilUpdate,
|
||||
user=Depends(get_current_user)):
|
||||
with db() as conn:
|
||||
_check_dog_access(conn, dog_id, user["id"])
|
||||
existing = conn.execute(
|
||||
"SELECT id FROM futter_profil WHERE dog_id=?", (dog_id,)
|
||||
).fetchone()
|
||||
if existing:
|
||||
conn.execute("""
|
||||
UPDATE futter_profil
|
||||
SET futter_typ=COALESCE(?, futter_typ),
|
||||
marke=COALESCE(?, marke),
|
||||
kcal_tag=COALESCE(?, kcal_tag),
|
||||
portionen=COALESCE(?, portionen),
|
||||
notizen=COALESCE(?, notizen),
|
||||
updated_at=datetime('now')
|
||||
WHERE dog_id=?
|
||||
""", (body.futter_typ, body.marke, body.kcal_tag,
|
||||
body.portionen, body.notizen, dog_id))
|
||||
else:
|
||||
conn.execute("""
|
||||
INSERT INTO futter_profil
|
||||
(dog_id, futter_typ, marke, kcal_tag, portionen, notizen)
|
||||
VALUES (?, ?, ?, ?, ?, ?)
|
||||
""", (dog_id, body.futter_typ, body.marke, body.kcal_tag,
|
||||
body.portionen or 2, body.notizen))
|
||||
row = conn.execute(
|
||||
"SELECT * FROM futter_profil WHERE dog_id=?", (dog_id,)
|
||||
).fetchone()
|
||||
return dict(row)
|
||||
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# POST /dogs/{dog_id}/ernaehrung/ki-beratung
|
||||
# ------------------------------------------------------------------
|
||||
@router.post("/{dog_id}/ernaehrung/ki-beratung")
|
||||
async def ki_ernaehrung(dog_id: int, body: KiBeratungRequest,
|
||||
request: Request,
|
||||
user=Depends(get_current_user)):
|
||||
if not body.frage or len(body.frage.strip()) < 3:
|
||||
raise HTTPException(400, "Bitte stelle eine Frage.")
|
||||
if len(body.frage) > 800:
|
||||
raise HTTPException(400, "Frage zu lang (max. 800 Zeichen).")
|
||||
|
||||
with db() as conn:
|
||||
_check_dog_access(conn, dog_id, user["id"])
|
||||
|
||||
dog_name = body.dog_name or "unbekannt"
|
||||
rasse = body.rasse or "unbekannt"
|
||||
alter = body.alter or "unbekannt"
|
||||
gewicht = f"{body.gewicht} kg" if body.gewicht else "unbekannt"
|
||||
aktiv_str = "aktiv" if body.aktiv else "normal aktiv"
|
||||
|
||||
system = (
|
||||
"Du bist Ernährungsberater für Hunde. "
|
||||
"Antworte immer auf Deutsch, kurz und praktisch. "
|
||||
"Keine unnötigen Füllsätze. "
|
||||
"Weise bei ernsthaften Gesundheitsfragen immer auf den Tierarzt hin. "
|
||||
"Stelle keine medizinischen Diagnosen."
|
||||
)
|
||||
|
||||
prompt = (
|
||||
f"Hund: {dog_name}, Rasse: {rasse}, Alter: {alter}, "
|
||||
f"Gewicht: {gewicht}, Aktivität: {aktiv_str}.\n\n"
|
||||
f"Frage: {body.frage.strip()}\n\n"
|
||||
"Antworte konkret und praktisch, maximal 200 Wörter."
|
||||
)
|
||||
|
||||
try:
|
||||
antwort = await ki_module.complete(
|
||||
prompt=prompt,
|
||||
system=system,
|
||||
max_tokens=500,
|
||||
requires_premium=False,
|
||||
user_id=user["id"],
|
||||
)
|
||||
return {"antwort": antwort}
|
||||
except ki_module.KIUnavailableError as e:
|
||||
raise HTTPException(503, str(e))
|
||||
except Exception:
|
||||
raise HTTPException(500, "KI momentan nicht verfügbar.")
|
||||
|
|
@ -93,9 +93,9 @@
|
|||
</script>
|
||||
|
||||
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=694">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=694">
|
||||
<link rel="stylesheet" href="/css/components.css?v=694">
|
||||
<link rel="stylesheet" href="/css/design-system.css?v=696">
|
||||
<link rel="stylesheet" href="/css/layout.css?v=696">
|
||||
<link rel="stylesheet" href="/css/components.css?v=696">
|
||||
</head>
|
||||
<body>
|
||||
|
||||
|
|
@ -499,6 +499,14 @@
|
|||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-ernaehrung">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
<section class="page" id="page-personality">
|
||||
<div class="page-body page-container"></div>
|
||||
</section>
|
||||
|
||||
</main>
|
||||
|
||||
<!-- MOBILE BOTTOM NAVIGATION -->
|
||||
|
|
@ -562,7 +570,7 @@
|
|||
<script src="/js/api.js?v=94"></script>
|
||||
<script src="/js/ui.js?v=94"></script>
|
||||
<script src="/js/app.js?v=94"></script>
|
||||
<script src="/js/worlds.js?v=694"></script>
|
||||
<script src="/js/worlds.js?v=696"></script>
|
||||
|
||||
<!-- Feature-Seiten werden lazy geladen -->
|
||||
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@
|
|||
Router, State-Management, Navigation, Initialisierung.
|
||||
============================================================ */
|
||||
|
||||
const APP_VER = '695'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VER = '698'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
|
||||
const APP_VERSION = '1.3.0'; // ← semantische Version, wird bei make release gesetzt
|
||||
const IS_STAGING = location.hostname === 'staging.banyaro.app';
|
||||
|
||||
|
|
@ -76,6 +76,8 @@ const App = (() => {
|
|||
adoption: { title: 'Adoption', module: null },
|
||||
playdate: { title: 'Playdate', module: null, requiresAuth: true },
|
||||
wetter: { title: 'Wetter', module: null },
|
||||
ernaehrung: { title: 'Ernährung', module: null, requiresAuth: true },
|
||||
personality: { title: 'Persönlichkeitstest', module: null },
|
||||
};
|
||||
|
||||
// ----------------------------------------------------------
|
||||
|
|
|
|||
603
backend/static/js/pages/ernaehrung.js
Normal file
603
backend/static/js/pages/ernaehrung.js
Normal file
|
|
@ -0,0 +1,603 @@
|
|||
/* ============================================================
|
||||
BAN YARO — Ernährung
|
||||
Tabs: Kalorien-Rechner | Futter-Guide | Giftliste | KI-Berater
|
||||
============================================================ */
|
||||
|
||||
window.Page_ernaehrung = (() => {
|
||||
|
||||
let _container = null;
|
||||
let _appState = null;
|
||||
let _activeTab = 'rechner';
|
||||
let _profil = {};
|
||||
|
||||
const TABS = [
|
||||
{ key: 'rechner', label: 'Kalorien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>' },
|
||||
{ key: 'guide', label: 'Futter-Guide', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#book-open"></use></svg>' },
|
||||
{ key: 'gift', label: 'Giftliste', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#warning"></use></svg>' },
|
||||
{ key: 'ki', label: 'KI-Berater', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>' },
|
||||
];
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// Escape helper
|
||||
// ------------------------------------------------------------------
|
||||
function _esc(s) {
|
||||
if (s == null) return '';
|
||||
return String(s)
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// LIFECYCLE
|
||||
// ------------------------------------------------------------------
|
||||
async function init(container, appState, params) {
|
||||
_container = container;
|
||||
_appState = appState;
|
||||
if (params?.tab && TABS.some(t => t.key === params.tab)) {
|
||||
_activeTab = params.tab;
|
||||
}
|
||||
await _render();
|
||||
}
|
||||
|
||||
async function refresh() {
|
||||
await _render();
|
||||
}
|
||||
|
||||
async function onDogChange() {
|
||||
_profil = {};
|
||||
await _render();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// RENDER
|
||||
// ------------------------------------------------------------------
|
||||
async function _render() {
|
||||
if (!_appState.activeDog) {
|
||||
_container.innerHTML = UI.emptyState({
|
||||
icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bowl-food"></use></svg>',
|
||||
title: 'Noch kein Hund angelegt',
|
||||
text: 'Erstelle zuerst ein Hundeprofil.',
|
||||
action: `<button class="btn btn-primary" onclick="App.navigate('dog-profile')">Profil erstellen</button>`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Profil laden
|
||||
const dog = _appState.activeDog;
|
||||
try {
|
||||
_profil = await API.get(`/dogs/${dog.id}/ernaehrung`);
|
||||
} catch (_) {
|
||||
_profil = {};
|
||||
}
|
||||
|
||||
_container.innerHTML = `
|
||||
<div class="by-tabs" id="ern-tabs"></div>
|
||||
<div id="ern-tab-content"></div>
|
||||
`;
|
||||
|
||||
_renderTabBar();
|
||||
_renderTab();
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB-BAR
|
||||
// ------------------------------------------------------------------
|
||||
function _renderTabBar() {
|
||||
const el = _container.querySelector('#ern-tabs');
|
||||
if (!el) return;
|
||||
el.innerHTML = TABS.map(t => `
|
||||
<button class="by-tab${t.key === _activeTab ? ' active' : ''}"
|
||||
data-tab="${t.key}">
|
||||
${t.icon} ${t.label}
|
||||
</button>`).join('');
|
||||
el.querySelectorAll('.by-tab').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
_activeTab = btn.dataset.tab;
|
||||
el.querySelectorAll('.by-tab').forEach(b => b.classList.remove('active'));
|
||||
btn.classList.add('active');
|
||||
_renderTab();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function _renderTab() {
|
||||
const el = _container.querySelector('#ern-tab-content');
|
||||
if (!el) return;
|
||||
switch (_activeTab) {
|
||||
case 'rechner': _renderRechner(el); break;
|
||||
case 'guide': _renderGuide(el); break;
|
||||
case 'gift': _renderGift(el); break;
|
||||
case 'ki': _renderKi(el); break;
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB 1: KALORIEN-RECHNER
|
||||
// ------------------------------------------------------------------
|
||||
function _renderRechner(el) {
|
||||
const dog = _appState.activeDog;
|
||||
|
||||
// Auto-Werte aus Hundeprofil
|
||||
const gewichtDefault = dog?.gewicht || '';
|
||||
const alterDefault = dog?.alter || '';
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:var(--space-4) 0">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
Berechne den täglichen Kalorienbedarf deines Hundes.
|
||||
</p>
|
||||
|
||||
<div class="by-form-group">
|
||||
<label class="by-label">Gewicht (kg)</label>
|
||||
<input id="ern-gewicht" type="number" step="0.1" min="0.5" max="100"
|
||||
class="by-input" value="${_esc(gewichtDefault)}" placeholder="z. B. 15">
|
||||
</div>
|
||||
|
||||
<div class="by-form-group">
|
||||
<label class="by-label">Alter (Jahre)</label>
|
||||
<input id="ern-alter" type="number" step="0.5" min="0" max="25"
|
||||
class="by-input" value="${_esc(alterDefault)}" placeholder="z. B. 3">
|
||||
</div>
|
||||
|
||||
<div class="by-form-group">
|
||||
<label class="by-label">Aktivität</label>
|
||||
<select id="ern-aktivitaet" class="by-select">
|
||||
<option value="gering">Gering (Couch-Hund)</option>
|
||||
<option value="normal" selected>Normal</option>
|
||||
<option value="aktiv">Aktiv</option>
|
||||
<option value="sport">Sehr aktiv (Sport)</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="by-form-group">
|
||||
<label class="by-label">Kastriert</label>
|
||||
<div style="display:flex;gap:var(--space-3)">
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ern-kastriert" value="ja"> Ja
|
||||
</label>
|
||||
<label style="display:flex;align-items:center;gap:6px;cursor:pointer">
|
||||
<input type="radio" name="ern-kastriert" value="nein" checked> Nein
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button class="btn btn-primary" id="ern-rechner-btn" style="width:100%">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#calculator"></use></svg>
|
||||
Berechnen
|
||||
</button>
|
||||
|
||||
<div id="ern-rechner-result" style="display:none;margin-top:var(--space-5)"></div>
|
||||
|
||||
<!-- Profil speichern -->
|
||||
<div id="ern-profil-speichern" style="display:none;margin-top:var(--space-4)">
|
||||
<h4 style="font-size:var(--text-sm);margin-bottom:var(--space-2)">Profil speichern</h4>
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
<div class="by-form-group" style="margin:0">
|
||||
<label class="by-label">Futter-Typ</label>
|
||||
<select id="ern-prof-typ" class="by-select">
|
||||
<option value="">-- wählen --</option>
|
||||
<option value="trocken"${_profil.futter_typ === 'trocken' ? ' selected' : ''}>Trockenfutter</option>
|
||||
<option value="nass"${_profil.futter_typ === 'nass' ? ' selected' : ''}>Nassfutter</option>
|
||||
<option value="barf"${_profil.futter_typ === 'barf' ? ' selected' : ''}>BARF</option>
|
||||
<option value="mix"${_profil.futter_typ === 'mix' ? ' selected' : ''}>Mix</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="by-form-group" style="margin:0">
|
||||
<label class="by-label">Marke / Produkt</label>
|
||||
<input id="ern-prof-marke" type="text" class="by-input"
|
||||
value="${_esc(_profil.marke)}" placeholder="z. B. Royal Canin">
|
||||
</div>
|
||||
<div class="by-form-group" style="margin:0">
|
||||
<label class="by-label">Portionen pro Tag</label>
|
||||
<input id="ern-prof-portionen" type="number" min="1" max="6"
|
||||
class="by-input" value="${_profil.portionen || 2}">
|
||||
</div>
|
||||
<div class="by-form-group" style="margin:0">
|
||||
<label class="by-label">Notizen</label>
|
||||
<textarea id="ern-prof-notizen" class="by-input" rows="2"
|
||||
placeholder="Besonderheiten, Allergien...">${_esc(_profil.notizen)}</textarea>
|
||||
</div>
|
||||
<button class="btn btn-secondary" id="ern-prof-save-btn">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#floppy-disk"></use></svg>
|
||||
Profil speichern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelector('#ern-rechner-btn').addEventListener('click', () => _berechne(el));
|
||||
}
|
||||
|
||||
function _berechne(el) {
|
||||
const gewicht = parseFloat(el.querySelector('#ern-gewicht').value);
|
||||
const aktivitaet = el.querySelector('#ern-aktivitaet').value;
|
||||
const kastriert = el.querySelector('input[name="ern-kastriert"]:checked')?.value === 'ja';
|
||||
|
||||
if (!gewicht || gewicht < 0.5) {
|
||||
UI.toast.warning('Bitte ein gültiges Gewicht eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
const rer = 70 * Math.pow(gewicht, 0.75);
|
||||
const faktoren = {
|
||||
gering: { intakt: 1.2, kastriert: 1.0 },
|
||||
normal: { intakt: 1.6, kastriert: 1.4 },
|
||||
aktiv: { intakt: 1.8, kastriert: 1.6 },
|
||||
sport: { intakt: 2.1, kastriert: 1.9 },
|
||||
};
|
||||
const kcal = Math.round(rer * faktoren[aktivitaet][kastriert ? 'kastriert' : 'intakt']);
|
||||
|
||||
// Umrechnung in Futtermengen
|
||||
const trocken = Math.round(kcal / 3.5); // ~350 kcal/100g
|
||||
const nass = Math.round(kcal / 0.85); // ~85 kcal/100g
|
||||
const barf = Math.round(kcal / 1.5); // ~150 kcal/100g
|
||||
|
||||
const kcalFormatted = kcal.toLocaleString('de-DE');
|
||||
|
||||
const resultEl = el.querySelector('#ern-rechner-result');
|
||||
resultEl.style.display = '';
|
||||
resultEl.innerHTML = `
|
||||
<div style="text-align:center;padding:var(--space-4);
|
||||
background:var(--c-primary);color:#fff;
|
||||
border-radius:var(--radius-lg);margin-bottom:var(--space-4)">
|
||||
<div style="font-size:var(--text-2xl);font-weight:700">ca. ${kcalFormatted} kcal</div>
|
||||
<div style="font-size:var(--text-sm);opacity:0.85">pro Tag</div>
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:var(--space-3)">
|
||||
<div style="background:var(--c-surface);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);border:1px solid var(--c-border)">
|
||||
<div style="font-weight:600;margin-bottom:4px">🌾 Trockenfutter</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
(~350 kcal/100g)
|
||||
</div>
|
||||
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
|
||||
${trocken} g / Tag
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
= ${Math.round(trocken/2)} g morgens + ${Math.round(trocken/2)} g abends
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--c-surface);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);border:1px solid var(--c-border)">
|
||||
<div style="font-weight:600;margin-bottom:4px">🥫 Nassfutter</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
(~85 kcal/100g)
|
||||
</div>
|
||||
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
|
||||
${nass} g / Tag
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
= ${Math.round(nass/2)} g morgens + ${Math.round(nass/2)} g abends
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="background:var(--c-surface);border-radius:var(--radius-md);
|
||||
padding:var(--space-3);border:1px solid var(--c-border)">
|
||||
<div style="font-weight:600;margin-bottom:4px">🥩 BARF</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
(~150 kcal/100g)
|
||||
</div>
|
||||
<div style="font-size:var(--text-lg);font-weight:600;margin-top:6px">
|
||||
${barf} g / Tag
|
||||
</div>
|
||||
<div style="font-size:var(--text-sm);color:var(--c-text-secondary)">
|
||||
= ${Math.round(barf/2)} g morgens + ${Math.round(barf/2)} g abends
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-3)">
|
||||
Richtwert nach Nationaler Forschungsratsformel (NRC). Immer den Körperzustand beobachten.
|
||||
</p>
|
||||
`;
|
||||
|
||||
// Profil-Speichern einblenden und kcal vorbelegen
|
||||
const profilSection = el.querySelector('#ern-profil-speichern');
|
||||
profilSection.style.display = '';
|
||||
|
||||
// kcal für Speichern merken
|
||||
profilSection.dataset.kcal = kcal;
|
||||
|
||||
el.querySelector('#ern-prof-save-btn').onclick = () => _speichereProfil(el, kcal);
|
||||
}
|
||||
|
||||
async function _speichereProfil(el, kcal) {
|
||||
const dog = _appState.activeDog;
|
||||
const futter_typ = el.querySelector('#ern-prof-typ').value || null;
|
||||
const marke = el.querySelector('#ern-prof-marke').value.trim() || null;
|
||||
const portionen = parseInt(el.querySelector('#ern-prof-portionen').value) || 2;
|
||||
const notizen = el.querySelector('#ern-prof-notizen').value.trim() || null;
|
||||
|
||||
const btn = el.querySelector('#ern-prof-save-btn');
|
||||
await UI.asyncButton(btn, async () => {
|
||||
try {
|
||||
_profil = await API.put(`/dogs/${dog.id}/ernaehrung`, {
|
||||
futter_typ, marke, kcal_tag: kcal, portionen, notizen,
|
||||
});
|
||||
UI.toast.success('Profil gespeichert.');
|
||||
} catch (err) {
|
||||
UI.toast.error(err.message || 'Fehler beim Speichern.');
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB 2: FUTTER-GUIDE
|
||||
// ------------------------------------------------------------------
|
||||
function _renderGuide(el) {
|
||||
const cards = [
|
||||
{
|
||||
id: 'barf',
|
||||
emoji: '🥩',
|
||||
titel: 'BARF (Rohfütterung)',
|
||||
inhalt: `
|
||||
<p><strong>Zusammensetzung:</strong> 70 % Muskelfleisch, 10 % rohe Knochen, 10 % Organe, 10 % Gemüse & Obst</p>
|
||||
<p><strong>Vorteile:</strong> Naturnahste Ernährungsform, glänzendes Fell, weniger Kot, keine Zusatzstoffe</p>
|
||||
<p><strong>Risiken:</strong> Keimbelastung durch rohes Fleisch, Calcium-Phosphor-Balance muss stimmen, zeitaufwändig und teurer</p>
|
||||
<p><strong>Tipp:</strong> Niemals BARF und Trockenfutter in derselben Mahlzeit mischen — unterschiedliche Verdauungszeiten können zu Problemen führen.</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'nass',
|
||||
emoji: '🥫',
|
||||
titel: 'Nassfutter',
|
||||
inhalt: `
|
||||
<p><strong>Zusammensetzung:</strong> 70–80 % Wasseranteil, meist höherer Fleischanteil als Trockenfutter</p>
|
||||
<p><strong>Vorteile:</strong> Hunde trinken automatisch mehr (gut für die Niere), schmackhafter, gut für wählerische Hunde</p>
|
||||
<p><strong>Worauf achten:</strong> Erste Zutat auf der Liste = Fleisch (nicht „Tierische Nebenerzeugnisse"), kein Zucker, kein Karamell</p>
|
||||
<p><strong>Zähne:</strong> Schlechter für die Zahngesundheit als Trockenfutter — öfter Zähne putzen oder Kauartikel geben.</p>
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'trocken',
|
||||
emoji: '🌾',
|
||||
titel: 'Trockenfutter',
|
||||
inhalt: `
|
||||
<p><strong>Zusammensetzung:</strong> 6–10 % Wasser, ca. 350–400 kcal/100 g, konzentrierte Nährstoffe</p>
|
||||
<p><strong>Gute Zutaten:</strong> Benanntes Fleisch an erster Stelle (Huhn, Lachs), mind. 40 % Tierprotein, kein Getreide als Hauptzutat</p>
|
||||
<p><strong>Schlechte Zutaten:</strong> „Getreide" als erste Zutat, Zucker, Karamell, Konservierungsstoffe E320 / E321</p>
|
||||
<p><strong>Wichtig:</strong> Immer frisches Wasser bereitstellen — Trockenfutter enthält kaum Feuchtigkeit.</p>
|
||||
`,
|
||||
},
|
||||
];
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:var(--space-4) 0">
|
||||
<p style="font-size:var(--text-sm);color:var(--c-text-secondary);margin-bottom:var(--space-4)">
|
||||
Klicke auf eine Karte für Details.
|
||||
</p>
|
||||
${cards.map(c => `
|
||||
<div class="ern-guide-card" data-id="${c.id}"
|
||||
style="background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-lg);margin-bottom:var(--space-3);
|
||||
overflow:hidden;cursor:pointer">
|
||||
<div class="ern-guide-head"
|
||||
style="display:flex;align-items:center;justify-content:space-between;
|
||||
padding:var(--space-3) var(--space-4)">
|
||||
<span style="font-weight:600;font-size:var(--text-base)">
|
||||
${c.emoji} ${c.titel}
|
||||
</span>
|
||||
<svg class="ph-icon ern-guide-chevron" aria-hidden="true"
|
||||
style="transition:transform .2s">
|
||||
<use href="/icons/phosphor.svg#caret-down"></use>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="ern-guide-body"
|
||||
style="display:none;padding:0 var(--space-4) var(--space-3);
|
||||
font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
line-height:1.6">
|
||||
${c.inhalt}
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
`;
|
||||
|
||||
el.querySelectorAll('.ern-guide-card').forEach(card => {
|
||||
card.querySelector('.ern-guide-head').addEventListener('click', () => {
|
||||
const body = card.querySelector('.ern-guide-body');
|
||||
const chevron = card.querySelector('.ern-guide-chevron');
|
||||
const open = body.style.display !== 'none';
|
||||
body.style.display = open ? 'none' : '';
|
||||
chevron.style.transform = open ? '' : 'rotate(180deg)';
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB 3: GIFTLISTE
|
||||
// ------------------------------------------------------------------
|
||||
function _renderGift(el) {
|
||||
const items = [
|
||||
{ emoji: '🍫', name: 'Schokolade', grund: 'Theobromin → Herzrasen, Krämpfe, kann tödlich sein' },
|
||||
{ emoji: '🍇', name: 'Trauben & Rosinen', grund: 'Nierenversagen — auch kleinste Mengen gefährlich' },
|
||||
{ emoji: '🧅', name: 'Zwiebeln & Knoblauch', grund: 'Zerstören rote Blutkörperchen → Anämie' },
|
||||
{ emoji: '🥑', name: 'Avocado', grund: 'Persin → Erbrechen, Durchfall, Atemnot' },
|
||||
{ emoji: '🌰', name: 'Macadamia-Nüsse', grund: 'Lähmungserscheinungen, Zittern, Erbrechen' },
|
||||
{ emoji: '🍬', name: 'Xylitol (Süßstoff)', grund: 'Schwere Leberschäden, Unterzucker — oft in Kaugummi' },
|
||||
{ emoji: '🥛', name: 'Milch & Milchprodukte', grund: 'Laktose-Intoleranz bei vielen Hunden → Durchfall' },
|
||||
{ emoji: '🦴', name: 'Gekochte Knochen', grund: 'Splitter → innere Verletzungen, Darmverschluss' },
|
||||
{ emoji: '☕', name: 'Koffein (Kaffee, Tee)', grund: 'Herzrasen, Zittern, Nervensystem' },
|
||||
{ emoji: '🧂', name: 'Salz', grund: 'Natriumvergiftung → Erbrechen, Krämpfe' },
|
||||
];
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:var(--space-4) 0">
|
||||
<div style="background:#fff3cd;border:1px solid #ffc107;border-radius:var(--radius-md);
|
||||
padding:var(--space-3);margin-bottom:var(--space-4);
|
||||
font-size:var(--text-sm)">
|
||||
<strong>⚠️ Notfall-Tierarzt:</strong> Bei Verdacht auf Vergiftung sofort zum Tierarzt.
|
||||
Nicht abwarten, auch wenn noch keine Symptome sichtbar sind.
|
||||
</div>
|
||||
|
||||
<div style="display:grid;gap:var(--space-2)">
|
||||
${items.map(item => `
|
||||
<div style="background:#fff5f5;border:1px solid #fed7d7;
|
||||
border-radius:var(--radius-md);padding:var(--space-3)">
|
||||
<div style="display:flex;align-items:center;gap:var(--space-2)">
|
||||
<span style="font-size:1.4rem">${item.emoji}</span>
|
||||
<div>
|
||||
<div style="font-weight:600;font-size:var(--text-sm)">${_esc(item.name)}</div>
|
||||
<div style="font-size:var(--text-xs);color:#c53030">${_esc(item.grund)}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<p style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:var(--space-4)">
|
||||
Diese Liste ist nicht vollständig. Im Zweifel gilt: lieber weglassen.
|
||||
</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// TAB 4: KI-FUTTERBERATER
|
||||
// ------------------------------------------------------------------
|
||||
function _renderKi(el) {
|
||||
const dog = _appState.activeDog;
|
||||
|
||||
el.innerHTML = `
|
||||
<div style="padding:var(--space-4) 0">
|
||||
<div style="background:var(--c-surface-2,var(--c-surface));border-radius:var(--radius-md);
|
||||
padding:var(--space-3);margin-bottom:var(--space-4);
|
||||
font-size:var(--text-sm);color:var(--c-text-secondary);
|
||||
border:1px solid var(--c-border)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#robot"></use></svg>
|
||||
Der KI-Futterberater beantwortet Ernährungsfragen für
|
||||
<strong>${_esc(dog?.name || 'deinen Hund')}</strong>.
|
||||
Bei Gesundheitsfragen immer den Tierarzt zurate ziehen.
|
||||
</div>
|
||||
|
||||
<!-- Vorschläge -->
|
||||
<div style="display:flex;flex-wrap:wrap;gap:var(--space-2);margin-bottom:var(--space-3)">
|
||||
${[
|
||||
'Welches Futter empfiehlst du für meine Rasse?',
|
||||
'Wie oft soll ich meinen Hund füttern?',
|
||||
'Ist Getreide im Futter schlecht?',
|
||||
'Welche Leckerlis sind gesund?',
|
||||
].map(q => `
|
||||
<button class="btn btn-sm btn-secondary ern-ki-vorschlag"
|
||||
data-q="${_esc(q)}"
|
||||
style="font-size:var(--text-xs)">${_esc(q)}</button>
|
||||
`).join('')}
|
||||
</div>
|
||||
|
||||
<!-- Chat-Verlauf -->
|
||||
<div id="ern-ki-chat" style="min-height:80px;margin-bottom:var(--space-3)"></div>
|
||||
|
||||
<!-- Eingabe -->
|
||||
<div style="display:flex;gap:var(--space-2)">
|
||||
<textarea id="ern-ki-frage" class="by-input" rows="2"
|
||||
placeholder="Deine Frage zur Ernährung..."
|
||||
style="flex:1;resize:vertical"></textarea>
|
||||
<button class="btn btn-primary" id="ern-ki-send-btn"
|
||||
style="align-self:flex-end">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#paper-plane-tilt"></use></svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
// Vorschläge
|
||||
el.querySelectorAll('.ern-ki-vorschlag').forEach(btn => {
|
||||
btn.addEventListener('click', () => {
|
||||
el.querySelector('#ern-ki-frage').value = btn.dataset.q;
|
||||
el.querySelector('#ern-ki-frage').focus();
|
||||
});
|
||||
});
|
||||
|
||||
// Senden
|
||||
el.querySelector('#ern-ki-send-btn').addEventListener('click', () => _kiSenden(el));
|
||||
el.querySelector('#ern-ki-frage').addEventListener('keydown', e => {
|
||||
if (e.key === 'Enter' && (e.ctrlKey || e.metaKey)) _kiSenden(el);
|
||||
});
|
||||
}
|
||||
|
||||
async function _kiSenden(el) {
|
||||
const dog = _appState.activeDog;
|
||||
const frageEl = el.querySelector('#ern-ki-frage');
|
||||
const frage = frageEl.value.trim();
|
||||
if (!frage) {
|
||||
UI.toast.warning('Bitte eine Frage eingeben.');
|
||||
return;
|
||||
}
|
||||
|
||||
const chatEl = el.querySelector('#ern-ki-chat');
|
||||
const sendBtn = el.querySelector('#ern-ki-send-btn');
|
||||
|
||||
// Userfrage anzeigen
|
||||
chatEl.insertAdjacentHTML('beforeend', `
|
||||
<div style="display:flex;justify-content:flex-end;margin-bottom:var(--space-2)">
|
||||
<div style="background:var(--c-primary);color:#fff;border-radius:var(--radius-md);
|
||||
padding:var(--space-2) var(--space-3);max-width:80%;font-size:var(--text-sm)">
|
||||
${_esc(frage)}
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
frageEl.value = '';
|
||||
|
||||
// KI-Antwort Placeholder
|
||||
const placeholderId = `ern-ki-placeholder-${Date.now()}`;
|
||||
chatEl.insertAdjacentHTML('beforeend', `
|
||||
<div id="${placeholderId}" style="margin-bottom:var(--space-3)">
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
|
||||
font-size:var(--text-sm);color:var(--c-text-muted)">
|
||||
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#circle-notch"></use></svg>
|
||||
Denke nach…
|
||||
</div>
|
||||
</div>
|
||||
`);
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
|
||||
await UI.asyncButton(sendBtn, async () => {
|
||||
let antwort = '';
|
||||
try {
|
||||
const result = await API.post(`/dogs/${dog.id}/ernaehrung/ki-beratung`, {
|
||||
frage,
|
||||
dog_name: dog?.name || null,
|
||||
rasse: dog?.rasse || null,
|
||||
alter: dog?.alter != null ? String(dog.alter) : null,
|
||||
gewicht: dog?.gewicht || null,
|
||||
aktiv: false,
|
||||
});
|
||||
antwort = result.antwort || 'Keine Antwort erhalten.';
|
||||
} catch (err) {
|
||||
if (err.status === 503) {
|
||||
antwort = 'Die KI ist momentan nicht verfügbar. Bitte später versuchen.';
|
||||
} else {
|
||||
antwort = 'Fehler bei der KI-Anfrage. Bitte später erneut versuchen.';
|
||||
}
|
||||
}
|
||||
|
||||
const antwortHtml = _esc(antwort)
|
||||
.replace(/\n\n/g, '</p><p style="margin:var(--space-1) 0">')
|
||||
.replace(/\n/g, '<br>');
|
||||
|
||||
const placeholder = document.getElementById(placeholderId);
|
||||
if (placeholder) {
|
||||
placeholder.innerHTML = `
|
||||
<div style="background:var(--c-surface);border:1px solid var(--c-border);
|
||||
border-radius:var(--radius-md);padding:var(--space-2) var(--space-3);
|
||||
font-size:var(--text-sm);line-height:1.6;max-width:90%">
|
||||
<p style="margin:0">${antwortHtml}</p>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
chatEl.scrollTop = chatEl.scrollHeight;
|
||||
});
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------
|
||||
// PUBLIC API
|
||||
// ------------------------------------------------------------------
|
||||
return { init, refresh, onDogChange };
|
||||
|
||||
})();
|
||||
|
|
@ -3,7 +3,7 @@
|
|||
Offline-Cache + Push Notifications + Tile-Cache
|
||||
============================================================ */
|
||||
|
||||
const CACHE_VERSION = 'by-v695';
|
||||
const CACHE_VERSION = 'by-v698';
|
||||
const CACHE_STATIC = `${CACHE_VERSION}-static`;
|
||||
const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten
|
||||
const CACHE_API = 'ban-yaro-api-v1'; // API-Response-Cache
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue