Feature: Aktive Erinnerungen, Versicherung, Verhaltensprotokoll, Hundefreundliche Orte (SW by-v874)

This commit is contained in:
rene 2026-05-11 22:24:42 +02:00
parent 83034c0db0
commit b818f85f36
11 changed files with 589 additions and 14 deletions

View file

@ -2210,6 +2210,45 @@ def _migrate(conn_factory):
except Exception: except Exception:
pass pass
# Versicherungs-Verwaltung
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS dog_insurance (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
anbieter TEXT NOT NULL,
police_nr TEXT,
jahresbeitrag REAL,
kontakt TEXT,
ablaufdatum TEXT,
notizen TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
logger.info("Migration: dog_insurance bereit.")
except Exception as e:
logger.warning(f"Migration dog_insurance: {e}")
# Verhaltens-Protokoll
try:
conn.execute("""
CREATE TABLE IF NOT EXISTS behavior_log (
id INTEGER PRIMARY KEY AUTOINCREMENT,
dog_id INTEGER NOT NULL REFERENCES dogs(id) ON DELETE CASCADE,
datum TEXT NOT NULL,
uhrzeit TEXT,
kategorie TEXT NOT NULL,
intensitaet INTEGER NOT NULL DEFAULT 3,
trigger TEXT,
notiz TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)
""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_behavior_dog ON behavior_log(dog_id, datum DESC)")
logger.info("Migration: behavior_log bereit.")
except Exception as e:
logger.warning(f"Migration behavior_log: {e}")
# route_dogs: bestehende Routen allen Hunden des Users zuweisen # route_dogs: bestehende Routen allen Hunden des Users zuweisen
try: try:
existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0] existing = conn.execute("SELECT COUNT(*) FROM route_dogs").fetchone()[0]

View file

@ -376,7 +376,7 @@ if STAGING and os.path.isdir(PROD_MEDIA_DIR):
else: else:
app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media") app.mount("/media", StaticFiles(directory=MEDIA_DIR), name="media")
APP_VER = "873" # muss mit APP_VER in app.js übereinstimmen APP_VER = "874" # 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

@ -569,3 +569,190 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
}) })
return vorschlaege return vorschlaege
# ==================================================================
# VERSICHERUNGS-VERWALTUNG
# ==================================================================
class InsuranceCreate(BaseModel):
anbieter: str
police_nr: Optional[str] = None
jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None
ablaufdatum: Optional[str] = None
notizen: Optional[str] = None
class InsuranceUpdate(BaseModel):
anbieter: Optional[str] = None
police_nr: Optional[str] = None
jahresbeitrag: Optional[float] = None
kontakt: Optional[str] = None
ablaufdatum: Optional[str] = None
notizen: Optional[str] = None
@router.get("/{dog_id}/insurance")
async def get_insurance(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone() or (_ for _ in ()).throw(HTTPException(404))
rows = conn.execute(
"SELECT * FROM dog_insurance WHERE dog_id=? ORDER BY created_at DESC", (dog_id,)
).fetchall()
return [dict(r) for r in rows]
@router.post("/{dog_id}/insurance", status_code=201)
async def create_insurance(dog_id: int, data: InsuranceCreate, user=Depends(get_current_user)):
with db() as conn:
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
if not dog:
raise HTTPException(404)
cur = conn.execute(
"""INSERT INTO dog_insurance (dog_id, anbieter, police_nr, jahresbeitrag, kontakt, ablaufdatum, notizen)
VALUES (?,?,?,?,?,?,?)""",
(dog_id, data.anbieter, data.police_nr, data.jahresbeitrag, data.kontakt, data.ablaufdatum, data.notizen)
)
row = conn.execute("SELECT * FROM dog_insurance WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.patch("/{dog_id}/insurance/{ins_id}")
async def update_insurance(dog_id: int, ins_id: int, data: InsuranceUpdate, user=Depends(get_current_user)):
fields = {k: v for k, v in data.model_dump().items() if v is not None}
if not fields:
raise HTTPException(400, "Keine Änderungen.")
with db() as conn:
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
if not dog:
raise HTTPException(404)
set_clause = ", ".join(f"{k}=?" for k in fields)
conn.execute(f"UPDATE dog_insurance SET {set_clause} WHERE id=? AND dog_id=?",
list(fields.values()) + [ins_id, dog_id])
row = conn.execute("SELECT * FROM dog_insurance WHERE id=?", (ins_id,)).fetchone()
return dict(row) if row else {}
@router.delete("/{dog_id}/insurance/{ins_id}", status_code=204)
async def delete_insurance(dog_id: int, ins_id: int, user=Depends(get_current_user)):
with db() as conn:
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
if not dog:
raise HTTPException(404)
conn.execute("DELETE FROM dog_insurance WHERE id=? AND dog_id=?", (ins_id, dog_id))
# ==================================================================
# VERHALTENS-PROTOKOLL
# ==================================================================
BEHAVIOR_KATEGORIEN = {
"angst": {"label": "Angst / Panik", "icon": "smiley-nervous"},
"aggression": {"label": "Aggression", "icon": "warning"},
"ueberreaktion":{"label": "Überreaktion", "icon": "lightning"},
"ressource": {"label": "Ressourcenverteidigung", "icon": "lock"},
"separation": {"label": "Trennungsangst", "icon": "house"},
"leine": {"label": "Leinenprobleme", "icon": "path"},
"sozial": {"label": "Sozialkompetenz", "icon": "users"},
"sonstiges": {"label": "Sonstiges", "icon": "note"},
}
BEHAVIOR_TRIGGER = [
"fremde_hunde", "fremde_menschen", "kinder", "laerm_feuerwerk",
"laerm_gewitter", "auto_fahrrad", "tierarzt", "allein_zuhause",
"andere_tiere", "besucher_zuhause", "sonstiges"
]
TRIGGER_LABELS = {
"fremde_hunde": "Fremde Hunde", "fremde_menschen": "Fremde Menschen",
"kinder": "Kinder", "laerm_feuerwerk": "Feuerwerk/Knaller",
"laerm_gewitter": "Gewitter", "auto_fahrrad": "Autos/Fahrräder",
"tierarzt": "Tierarztbesuch", "allein_zuhause": "Allein zuhause",
"andere_tiere": "Andere Tiere", "besucher_zuhause": "Besucher",
"sonstiges": "Sonstiges"
}
class BehaviorCreate(BaseModel):
datum: str
uhrzeit: Optional[str] = None
kategorie: str
intensitaet: int = 3
trigger: Optional[str] = None
notiz: Optional[str] = None
@router.get("/{dog_id}/behavior")
async def get_behavior(dog_id: int, user=Depends(get_current_user)):
with db() as conn:
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
if not dog:
raise HTTPException(404)
rows = conn.execute(
"SELECT * FROM behavior_log WHERE dog_id=? ORDER BY datum DESC, uhrzeit DESC LIMIT 100",
(dog_id,)
).fetchall()
return {
"entries": [dict(r) for r in rows],
"kategorien": BEHAVIOR_KATEGORIEN,
"trigger_labels": TRIGGER_LABELS,
}
@router.post("/{dog_id}/behavior", status_code=201)
async def create_behavior(dog_id: int, data: BehaviorCreate, user=Depends(get_current_user)):
with db() as conn:
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
if not dog:
raise HTTPException(404)
if data.kategorie not in BEHAVIOR_KATEGORIEN:
raise HTTPException(400, "Unbekannte Kategorie.")
cur = conn.execute(
"""INSERT INTO behavior_log (dog_id, datum, uhrzeit, kategorie, intensitaet, trigger, notiz)
VALUES (?,?,?,?,?,?,?)""",
(dog_id, data.datum, data.uhrzeit, data.kategorie,
max(1, min(5, data.intensitaet)), data.trigger, data.notiz)
)
row = conn.execute("SELECT * FROM behavior_log WHERE id=?", (cur.lastrowid,)).fetchone()
return dict(row)
@router.delete("/{dog_id}/behavior/{entry_id}", status_code=204)
async def delete_behavior(dog_id: int, entry_id: int, user=Depends(get_current_user)):
with db() as conn:
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
if not dog:
raise HTTPException(404)
conn.execute("DELETE FROM behavior_log WHERE id=? AND dog_id=?", (entry_id, dog_id))
@router.get("/{dog_id}/reminders")
async def get_upcoming_reminders(dog_id: int, user=Depends(get_current_user)):
"""Bevorstehende Erinnerungen der nächsten 30 Tage + überfällige."""
from datetime import timedelta
today = date.today()
in30 = today + timedelta(days=30)
with db() as conn:
dog = conn.execute("SELECT id FROM dogs WHERE id=? AND user_id=?", (dog_id, user["id"])).fetchone()
if not dog:
raise HTTPException(404)
rows = conn.execute(
"""SELECT id, typ, bezeichnung, naechstes
FROM health
WHERE dog_id=? AND naechstes IS NOT NULL
AND (erinnerung IS NULL OR erinnerung = 1)
AND typ IN ('impfung','entwurmung','medikament')
ORDER BY naechstes ASC""",
(dog_id,)
).fetchall()
result = []
for r in rows:
try:
d = date.fromisoformat(r["naechstes"])
except Exception:
continue
delta = (d - today).days
if delta < -30 or delta > 30:
continue
result.append({**dict(r), "delta_tage": delta, "ueberfaellig": delta < 0})
return result

View file

@ -46,8 +46,9 @@ OSM_QUERIES = {
'drinking_water': '[out:json][timeout:20];node["amenity"="drinking_water"]({bbox});out;', 'drinking_water': '[out:json][timeout:20];node["amenity"="drinking_water"]({bbox});out;',
'tierarzt': '[out:json][timeout:25];(node["amenity"="veterinary"]({bbox});way["amenity"="veterinary"]({bbox}););out center;', 'tierarzt': '[out:json][timeout:25];(node["amenity"="veterinary"]({bbox});way["amenity"="veterinary"]({bbox}););out center;',
'shop': '[out:json][timeout:25];(node["shop"="pet"]({bbox});way["shop"="pet"]({bbox}););out center;', 'shop': '[out:json][timeout:25];(node["shop"="pet"]({bbox});way["shop"="pet"]({bbox}););out center;',
'restaurant': '[out:json][timeout:35];(node["amenity"="restaurant"]({bbox});way["amenity"="restaurant"]({bbox});node["amenity"="cafe"]({bbox});way["amenity"="cafe"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;', 'restaurant': '[out:json][timeout:35];(node["amenity"~"restaurant|cafe"]["dog"~"yes|allowed"]({bbox});way["amenity"~"restaurant|cafe"]["dog"~"yes|allowed"]({bbox});node["amenity"="biergarten"]({bbox});way["amenity"="biergarten"]({bbox}););out center;',
'bank': '[out:json][timeout:20];node["amenity"="bench"]({bbox});out;', 'bank': '[out:json][timeout:20];node["amenity"="bench"]({bbox});out;',
'hotel': '[out:json][timeout:25];(node["tourism"~"hotel|guest_house|hostel"]["dog"~"yes|allowed"]({bbox});way["tourism"~"hotel|guest_house|hostel"]["dog"~"yes|allowed"]({bbox}););out center;',
} }
# Ab dieser Anzahl Meldungen wird ein Marker ausgeblendet # Ab dieser Anzahl Meldungen wird ein Marker ausgeblendet

View file

@ -214,16 +214,20 @@ async def _job_health_reminders():
logger.info(f"Health-Reminder Job läuft für {today}") logger.info(f"Health-Reminder Job läuft für {today}")
in3 = today + timedelta(days=3)
with db() as conn: with db() as conn:
# Alle fälligen Einträge der nächsten 7 Tage + gestrige (überfällig) # Alle fälligen Einträge der nächsten 7 Tage + gestrige (überfällig)
# erinnerung=0 → User hat diese Erinnerung deaktiviert
rows = conn.execute(""" rows = conn.execute("""
SELECT h.id, h.typ, h.bezeichnung, h.naechstes, SELECT h.id, h.typ, h.bezeichnung, h.naechstes,
d.user_id, d.name AS hund_name d.user_id, d.name AS hund_name
FROM health h FROM health h
JOIN dogs d ON d.id = h.dog_id JOIN dogs d ON d.id = h.dog_id
WHERE h.naechstes IN (?, ?, ?) WHERE h.naechstes IN (?, ?, ?, ?)
AND h.typ IN ('impfung', 'entwurmung', 'medikament') AND h.typ IN ('impfung', 'entwurmung', 'medikament')
""", (str(today), str(in7), str(yesterday))).fetchall() AND (h.erinnerung IS NULL OR h.erinnerung = 1)
""", (str(today), str(in7), str(in3), str(yesterday))).fetchall()
sent_total = 0 sent_total = 0
for r in rows: for r in rows:
@ -233,6 +237,9 @@ async def _job_health_reminders():
if delta == 7: if delta == 7:
title = f"⏰ Erinnerung: {r['bezeichnung']}" title = f"⏰ Erinnerung: {r['bezeichnung']}"
body = f"In 7 Tagen fällig für {r['hund_name']}." body = f"In 7 Tagen fällig für {r['hund_name']}."
elif delta == 3:
title = f"⏰ Erinnerung: {r['bezeichnung']}"
body = f"In 3 Tagen fällig für {r['hund_name']} — bald vorbereiten."
elif delta == 0: elif delta == 0:
title = f"📅 Heute fällig: {r['bezeichnung']}" title = f"📅 Heute fällig: {r['bezeichnung']}"
body = f"Bitte heute erledigen — {r['hund_name']} wartet." body = f"Bitte heute erledigen — {r['hund_name']} wartet."

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=873"> <link rel="stylesheet" href="/css/design-system.css?v=874">
<link rel="stylesheet" href="/css/layout.css?v=873"> <link rel="stylesheet" href="/css/layout.css?v=874">
<link rel="stylesheet" href="/css/components.css?v=873"> <link rel="stylesheet" href="/css/components.css?v=874">
</head> </head>
<body> <body>
@ -583,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=873"></script> <script src="/js/api.js?v=874"></script>
<script src="/js/ui.js?v=873"></script> <script src="/js/ui.js?v=874"></script>
<script src="/js/app.js?v=873"></script> <script src="/js/app.js?v=874"></script>
<script src="/js/worlds.js?v=873"></script> <script src="/js/worlds.js?v=874"></script>
<!-- Feature-Seiten werden lazy geladen --> <!-- Feature-Seiten werden lazy geladen -->

View file

@ -219,6 +219,14 @@ const API = (() => {
gewichtVerlauf(dogId) { gewichtVerlauf(dogId) {
return get(`/dogs/${dogId}/health/gewicht`); return get(`/dogs/${dogId}/health/gewicht`);
}, },
reminders(dogId) { return get(`/dogs/${dogId}/reminders`); },
insuranceList(dogId) { return get(`/dogs/${dogId}/insurance`); },
insuranceCreate(dogId, d) { return post(`/dogs/${dogId}/insurance`, d); },
insuranceUpdate(dogId, id, d) { return patch(`/dogs/${dogId}/insurance/${id}`, d); },
insuranceDelete(dogId, id) { return del(`/dogs/${dogId}/insurance/${id}`); },
behaviorList(dogId) { return get(`/dogs/${dogId}/behavior`); },
behaviorCreate(dogId, d) { return post(`/dogs/${dogId}/behavior`, d); },
behaviorDelete(dogId, id) { return del(`/dogs/${dogId}/behavior/${id}`); },
}; };
// ---------------------------------------------------------- // ----------------------------------------------------------

View file

@ -3,7 +3,7 @@
Router, State-Management, Navigation, Initialisierung. Router, State-Management, Navigation, Initialisierung.
============================================================ */ ============================================================ */
const APP_VER = '873'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VER = '874'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen
const APP_VERSION = '1.5.1'; // ← semantische Version, wird bei make release gesetzt const APP_VERSION = '1.5.1'; // ← 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

View file

@ -22,6 +22,8 @@ window.Page_health = (() => {
{ key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' }, { key: 'allergie', label: 'Allergien', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#leaf"></use></svg>' },
{ key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' }, { key: 'dokument', label: 'Dokumente', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#file-text"></use></svg>' },
{ key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' }, { key: 'praxen', label: 'Praxen', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#first-aid"></use></svg>' },
{ key: 'versicherung', label: 'Versicherung', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shield-check"></use></svg>' },
{ key: 'verhalten', label: 'Verhalten', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#brain"></use></svg>' },
]; ];
const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' }; const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#gender-female"></use></svg>' };
@ -111,12 +113,14 @@ window.Page_health = (() => {
<div id="health-ki-berichte"></div> <div id="health-ki-berichte"></div>
<div id="health-terminvorschlaege"></div> <div id="health-terminvorschlaege"></div>
<div id="health-reminders"></div> <div id="health-reminders"></div>
<div id="health-reminders-banner" style="display:none;padding:0 0 var(--space-2);display:flex;flex-direction:column;gap:var(--space-1)"></div>
<div class="by-tabs" id="by-tabs"></div> <div class="by-tabs" id="by-tabs"></div>
<div id="by-tab-content"></div> <div id="by-tab-content"></div>
`; `;
_renderTabBar(); _renderTabBar();
UI.bindDogChip(_container, _appState); UI.bindDogChip(_container, _appState);
_loadRemindersBanner();
_container.querySelector('#health-ki-btn') _container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary); .addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn') _container.querySelector('#health-ki-tierarzt-btn')
@ -332,6 +336,8 @@ window.Page_health = (() => {
case 'allergie': content.innerHTML = _renderAllergien(entries); break; case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break; case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break; case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'versicherung': _renderVersicherung(content); break;
case 'verhalten': _renderVerhalten(content); break;
} }
_bindTabEvents(content); _bindTabEvents(content);
@ -3050,6 +3056,331 @@ window.Page_health = (() => {
}); });
} }
// ==============================================================
// BEVORSTEHENDE ERINNERUNGEN (Banner oben in der Health-Seite)
// ==============================================================
async function _loadRemindersBanner() {
const dog = _appState?.activeDog;
if (!dog) return;
const wrap = _container?.querySelector('#health-reminders-banner');
if (!wrap) return;
let items;
try { items = await API.health.reminders(dog.id); }
catch { return; }
if (!items.length) { wrap.style.display = 'none'; return; }
const TYPE_LABEL = { impfung: 'Impfung', entwurmung: 'Entwurmung', medikament: 'Medikament' };
const fmt = d => { try { const p = d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
wrap.style.display = '';
wrap.innerHTML = items.slice(0, 3).map(r => {
const overdue = r.ueberfaellig;
const color = overdue ? 'var(--c-danger,#ef4444)' : r.delta_tage <= 3 ? '#f59e0b' : 'var(--c-primary)';
const bg = overdue ? 'rgba(239,68,68,0.08)' : r.delta_tage <= 3 ? 'rgba(245,158,11,0.08)' : 'var(--c-primary-subtle)';
const label = overdue ? `Überfällig seit ${Math.abs(r.delta_tage)} Tag${Math.abs(r.delta_tage)!==1?'en':''}` :
r.delta_tage === 0 ? 'Heute fällig' :
`in ${r.delta_tage} Tag${r.delta_tage!==1?'en':''}`;
return `
<div style="display:flex;align-items:center;gap:var(--space-2);padding:var(--space-2) var(--space-3);
background:${bg};border-radius:var(--radius-md);border-left:3px solid ${color}">
<svg class="ph-icon" style="width:16px;height:16px;color:${color};flex-shrink:0" aria-hidden="true">
<use href="/icons/phosphor.svg#bell-ringing"></use>
</svg>
<div style="flex:1;min-width:0">
<span style="font-weight:600;font-size:var(--text-sm);color:var(--c-text)">${_esc(r.bezeichnung)}</span>
<span style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-left:var(--space-1)">${TYPE_LABEL[r.typ] || r.typ}</span>
</div>
<span style="font-size:var(--text-xs);font-weight:600;color:${color};white-space:nowrap">${label}</span>
</div>`;
}).join('');
}
// ==============================================================
// TAB: VERSICHERUNG
// ==============================================================
async function _renderVersicherung(content) {
const dog = _appState?.activeDog;
if (!dog) return;
content.innerHTML = `<div style="padding:var(--space-4) 0">
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner-gap"></use></svg>
</div></div>`;
let policies;
try { policies = await API.health.insuranceList(dog.id); }
catch { content.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Fehler beim Laden.</p>`; return; }
const _fmtDate = d => { if (!d) return ''; try { const p=d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
const _fmtEur = v => v ? `${v.toFixed(2).replace('.',',')} €/Jahr` : '';
const cardsHtml = policies.length ? policies.map(p => `
<div class="card" style="padding:var(--space-4);margin-bottom:var(--space-3)" data-ins-id="${p.id}">
<div style="display:flex;align-items:flex-start;justify-content:space-between;gap:var(--space-2)">
<div>
<div style="font-weight:700;font-size:var(--text-base)">${_esc(p.anbieter)}</div>
${p.police_nr ? `<div style="font-size:var(--text-xs);color:var(--c-text-muted);margin-top:2px">Police: ${_esc(p.police_nr)}</div>` : ''}
</div>
<div style="display:flex;gap:var(--space-1)">
<button class="btn btn-ghost btn-sm ins-edit-btn" data-id="${p.id}" style="padding:4px 8px">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#pencil"></use></svg>
</button>
<button class="btn btn-ghost btn-sm ins-del-btn" data-id="${p.id}" style="padding:4px 8px;color:var(--c-danger)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-2);margin-top:var(--space-3);font-size:var(--text-sm)">
<div><span style="color:var(--c-text-secondary)">Jahresbeitrag</span><br><strong>${_fmtEur(p.jahresbeitrag)}</strong></div>
<div><span style="color:var(--c-text-secondary)">Läuft ab</span><br><strong>${_fmtDate(p.ablaufdatum)}</strong></div>
${p.kontakt ? `<div style="grid-column:1/-1"><span style="color:var(--c-text-secondary)">Kontakt</span><br>${_esc(p.kontakt)}</div>` : ''}
${p.notizen ? `<div style="grid-column:1/-1"><span style="color:var(--c-text-secondary)">Notizen</span><br>${_esc(p.notizen)}</div>` : ''}
</div>
</div>`).join('') : `
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:2.5rem;height:2.5rem;margin-bottom:var(--space-3);display:block;margin-inline:auto" aria-hidden="true"><use href="/icons/phosphor.svg#shield-check"></use></svg>
<div style="font-size:var(--text-sm)">Noch keine Versicherung eingetragen.</div>
</div>`;
content.innerHTML = `<div style="padding:var(--space-4) 0">
${cardsHtml}
<button class="btn btn-primary" id="ins-add-btn" style="width:100%;margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
Versicherung eintragen
</button>
</div>`;
content.querySelector('#ins-add-btn')?.addEventListener('click', () => _openInsuranceForm(dog, null, () => _renderVersicherung(content)));
content.querySelectorAll('.ins-edit-btn').forEach(btn => {
const pol = policies.find(p => p.id === parseInt(btn.dataset.id));
btn.addEventListener('click', () => _openInsuranceForm(dog, pol, () => _renderVersicherung(content)));
});
content.querySelectorAll('.ins-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Versicherung löschen?')) return;
await API.health.insuranceDelete(dog.id, parseInt(btn.dataset.id));
_renderVersicherung(content);
});
});
}
function _openInsuranceForm(dog, existing, onSave) {
const id = `ins-form-${Date.now()}`;
const body = `<form id="${id}">
<div class="by-form-group"><label class="by-label">Anbieter *</label>
<input type="text" name="anbieter" class="form-control by-input" value="${_esc(existing?.anbieter||'')}" required placeholder="z. B. HUK-Coburg">
</div>
<div class="by-form-group"><label class="by-label">Police-Nr.</label>
<input type="text" name="police_nr" class="form-control by-input" value="${_esc(existing?.police_nr||'')}" placeholder="optional">
</div>
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="by-form-group"><label class="by-label">Jahresbeitrag ()</label>
<input type="number" name="jahresbeitrag" class="form-control by-input" value="${existing?.jahresbeitrag||''}" min="0" step="0.01" placeholder="z. B. 149.00">
</div>
<div class="by-form-group"><label class="by-label">Ablaufdatum</label>
<input type="date" name="ablaufdatum" class="form-control by-input" value="${existing?.ablaufdatum||''}">
</div>
</div>
<div class="by-form-group"><label class="by-label">Kontakt / Telefon</label>
<input type="text" name="kontakt" class="form-control by-input" value="${_esc(existing?.kontakt||'')}" placeholder="optional">
</div>
<div class="by-form-group"><label class="by-label">Notizen</label>
<textarea name="notizen" class="form-control by-input" rows="2">${_esc(existing?.notizen||'')}</textarea>
</div>
</form>`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="ins-save-btn" form="${id}">Speichern</button>`;
UI.modal.open({ title: existing ? 'Versicherung bearbeiten' : 'Versicherung eintragen', body, footer });
setTimeout(() => {
document.getElementById('ins-save-btn')?.addEventListener('click', async ev => {
ev.preventDefault();
const form = document.getElementById(id);
if (!form) return;
const fd = new FormData(form);
const data = {
anbieter: (fd.get('anbieter')||'').trim(),
police_nr: fd.get('police_nr')||null,
jahresbeitrag: fd.get('jahresbeitrag') ? parseFloat(fd.get('jahresbeitrag')) : null,
ablaufdatum: fd.get('ablaufdatum')||null,
kontakt: fd.get('kontakt')||null,
notizen: fd.get('notizen')||null,
};
if (!data.anbieter) { UI.toast.warning('Bitte Anbieter angeben.'); return; }
await UI.asyncButton(document.getElementById('ins-save-btn'), async () => {
try {
if (existing) await API.health.insuranceUpdate(dog.id, existing.id, data);
else await API.health.insuranceCreate(dog.id, data);
UI.modal.close();
UI.toast.success('Gespeichert.');
onSave();
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}, 50);
}
// ==============================================================
// TAB: VERHALTEN
// ==============================================================
const _KAT_LABELS = {
angst: 'Angst / Panik', aggression: 'Aggression', ueberreaktion: 'Überreaktion',
ressource: 'Ressourcenverteidigung', separation: 'Trennungsangst',
leine: 'Leinenprobleme', sozial: 'Sozialkompetenz', sonstiges: 'Sonstiges',
};
const _KAT_COLORS = {
angst: '#3b82f6', aggression: '#ef4444', ueberreaktion: '#f59e0b',
ressource: '#8b5cf6', separation: '#ec4899', leine: '#06b6d4',
sozial: '#22c55e', sonstiges: '#6b7280',
};
const _TRIGGER_LABELS = {
fremde_hunde: 'Fremde Hunde', fremde_menschen: 'Fremde Menschen', kinder: 'Kinder',
laerm_feuerwerk: 'Feuerwerk', laerm_gewitter: 'Gewitter', auto_fahrrad: 'Autos/Fahrräder',
tierarzt: 'Tierarztbesuch', allein_zuhause: 'Allein zuhause',
andere_tiere: 'Andere Tiere', besucher_zuhause: 'Besucher', sonstiges: 'Sonstiges',
};
async function _renderVerhalten(content) {
const dog = _appState?.activeDog;
if (!dog) return;
content.innerHTML = `<div style="padding:var(--space-4) 0">
<div style="text-align:center;color:var(--c-text-muted);padding:var(--space-4)">
<svg class="ph-icon" style="width:24px;height:24px;animation:spin 1s linear infinite" aria-hidden="true"><use href="/icons/phosphor.svg#spinner-gap"></use></svg>
</div></div>`;
let resp;
try { resp = await API.health.behaviorList(dog.id); }
catch { content.innerHTML = `<p style="color:var(--c-danger);padding:var(--space-4)">Fehler beim Laden.</p>`; return; }
const entries = resp.entries || [];
const fmtDate = d => { try { const p=d.split('-'); return `${parseInt(p[2])}.${parseInt(p[1])}.${p[0]}`; } catch { return d; } };
const listHtml = entries.length ? entries.map(e => {
const color = _KAT_COLORS[e.kategorie] || '#6b7280';
const katLabel = _KAT_LABELS[e.kategorie] || e.kategorie;
const trigLabel = _TRIGGER_LABELS[e.trigger] || e.trigger || '';
const dots = Array.from({length: 5}, (_,i) =>
`<div style="width:8px;height:8px;border-radius:50%;background:${i < e.intensitaet ? color : 'var(--c-border)'}"></div>`
).join('');
return `
<div class="card" style="padding:var(--space-3);margin-bottom:var(--space-2);display:flex;align-items:flex-start;gap:var(--space-3)">
<div style="width:3px;border-radius:2px;background:${color};align-self:stretch;flex-shrink:0"></div>
<div style="flex:1;min-width:0">
<div style="display:flex;align-items:center;gap:var(--space-2);flex-wrap:wrap">
<span style="font-weight:700;font-size:var(--text-sm);color:${color}">${_esc(katLabel)}</span>
${trigLabel ? `<span style="font-size:var(--text-xs);background:var(--c-surface-2);padding:1px 6px;border-radius:100px;color:var(--c-text-secondary)">${_esc(trigLabel)}</span>` : ''}
<span style="font-size:var(--text-xs);color:var(--c-text-muted);margin-left:auto">${fmtDate(e.datum)}${e.uhrzeit ? ' ' + e.uhrzeit : ''}</span>
</div>
<div style="display:flex;gap:3px;margin-top:4px">${dots}</div>
${e.notiz ? `<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:4px">${_esc(e.notiz)}</div>` : ''}
</div>
<button class="btn btn-ghost btn-sm beh-del-btn" data-id="${e.id}" style="padding:4px 6px;color:var(--c-danger);flex-shrink:0">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#trash"></use></svg>
</button>
</div>`;
}).join('') : `
<div style="text-align:center;padding:var(--space-6);color:var(--c-text-muted)">
<svg class="ph-icon" style="width:2.5rem;height:2.5rem;margin-bottom:var(--space-3);display:block;margin-inline:auto" aria-hidden="true"><use href="/icons/phosphor.svg#brain"></use></svg>
<div style="font-size:var(--text-sm)">Noch keine Einträge. Protokolliere auffälliges Verhalten um Muster zu erkennen.</div>
</div>`;
content.innerHTML = `<div style="padding:var(--space-4) 0">
${listHtml}
<button class="btn btn-primary" id="beh-add-btn" style="width:100%;margin-top:var(--space-2)">
<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#plus"></use></svg>
Verhalten erfassen
</button>
</div>`;
content.querySelector('#beh-add-btn')?.addEventListener('click', () => _openBehaviorForm(dog, () => _renderVerhalten(content)));
content.querySelectorAll('.beh-del-btn').forEach(btn => {
btn.addEventListener('click', async () => {
if (!window.confirm('Eintrag löschen?')) return;
await API.health.behaviorDelete(dog.id, parseInt(btn.dataset.id));
_renderVerhalten(content);
});
});
}
function _openBehaviorForm(dog, onSave) {
const id = `beh-form-${Date.now()}`;
const today = new Date().toISOString().slice(0, 10);
const nowTime = (() => { const d=new Date(); return `${String(d.getHours()).padStart(2,'0')}:${String(d.getMinutes()).padStart(2,'0')}`; })();
const body = `<form id="${id}">
<div style="display:grid;grid-template-columns:1fr 1fr;gap:var(--space-3)">
<div class="by-form-group"><label class="by-label">Datum</label>
<input type="date" name="datum" class="form-control by-input" value="${today}" required>
</div>
<div class="by-form-group"><label class="by-label">Uhrzeit</label>
<input type="time" name="uhrzeit" class="form-control by-input" value="${nowTime}">
</div>
</div>
<div class="by-form-group"><label class="by-label">Kategorie *</label>
<select name="kategorie" class="form-control by-select" required>
<option value=""> wählen </option>
${Object.entries(_KAT_LABELS).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
</select>
</div>
<div class="by-form-group"><label class="by-label">Intensität (1 = gering, 5 = stark)</label>
<div style="display:flex;gap:var(--space-2)">
${[1,2,3,4,5].map(n => `<button type="button" class="beh-int-btn" data-val="${n}"
style="flex:1;padding:10px;border-radius:8px;border:1.5px solid var(--c-border);
background:${n<=3?'var(--c-primary)':'var(--c-bg-card)'};
color:${n<=3?'#fff':'var(--c-text-secondary)'};font-weight:700;cursor:pointer">${n}</button>`).join('')}
</div>
<input type="hidden" name="intensitaet" value="3">
</div>
<div class="by-form-group"><label class="by-label">Auslöser</label>
<select name="trigger" class="form-control by-select">
<option value=""> unbekannt </option>
${Object.entries(_TRIGGER_LABELS).map(([k,v]) => `<option value="${k}">${v}</option>`).join('')}
</select>
</div>
<div class="by-form-group"><label class="by-label">Notiz</label>
<textarea name="notiz" class="form-control by-input" rows="2" placeholder="Was ist passiert?"></textarea>
</div>
</form>`;
const footer = `
<button class="btn btn-secondary" onclick="UI.modal.close()">Abbrechen</button>
<button class="btn btn-primary" id="beh-save-btn" form="${id}">Speichern</button>`;
UI.modal.open({ title: 'Verhalten erfassen', body, footer });
setTimeout(() => {
document.querySelectorAll('.beh-int-btn').forEach(btn => {
btn.addEventListener('click', () => {
const val = parseInt(btn.dataset.val);
document.querySelectorAll('.beh-int-btn').forEach((b,i) => {
b.style.background = i < val ? 'var(--c-primary)' : 'var(--c-bg-card)';
b.style.color = i < val ? '#fff' : 'var(--c-text-secondary)';
});
const hi = document.querySelector('[name="intensitaet"]');
if (hi) hi.value = val;
});
});
document.getElementById('beh-save-btn')?.addEventListener('click', async ev => {
ev.preventDefault();
const form = document.getElementById(id);
if (!form) return;
const fd = new FormData(form);
const data = {
datum: fd.get('datum'),
uhrzeit: fd.get('uhrzeit')||null,
kategorie: fd.get('kategorie'),
intensitaet: parseInt(fd.get('intensitaet')||'3'),
trigger: fd.get('trigger')||null,
notiz: (fd.get('notiz')||'').trim()||null,
};
if (!data.kategorie) { UI.toast.warning('Bitte Kategorie wählen.'); return; }
await UI.asyncButton(document.getElementById('beh-save-btn'), async () => {
try {
await API.health.behaviorCreate(dog.id, data);
UI.modal.close();
UI.toast.success('Eintrag gespeichert.');
onSave();
} catch (err) { UI.toast.error(err.message || 'Fehler.'); }
});
});
}, 50);
}
return { init, refresh, openNew, onDogChange }; return { init, refresh, openNew, onDogChange };
})(); })();

View file

@ -75,7 +75,7 @@ window.Page_map = (() => {
// z: zIndexOffset — höher = weiter oben bei Überlappung // z: zIndexOffset — höher = weiter oben bei Überlappung
const TYPEN = { const TYPEN = {
restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Restaurant', color: '#F97316', z: 10 }, restaurant: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#fork-knife"></use></svg>', label: 'Hundefreundl. Café/Restaurant', color: '#F97316', z: 10 },
freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 }, freilauf: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#dog"></use></svg>', label: 'Freilauf', color: '#22C55E', z: 20 },
shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 }, shop: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#shopping-cart"></use></svg>', label: 'Shop', color: '#3B82F6', z: 15 },
kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 }, kotbeutel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bag"></use></svg>', label: 'Kotbeutel', color: '#84A98C', z: 5 },
@ -92,6 +92,7 @@ window.Page_map = (() => {
treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 }, treffpunkt: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#handshake"></use></svg>', label: 'Treffpunkt', color: '#7C3AED', z: 25 },
community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 }, community: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#push-pin"></use></svg>', label: 'Sonstiges', color: '#F59E0B', z: 30 },
zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 }, zuechter: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#certificate"></use></svg>', label: 'Züchter', color: '#7C3AED', z: 50 },
hotel: { icon: '<svg class="ph-icon" aria-hidden="true"><use href="/icons/phosphor.svg#bed"></use></svg>', label: 'Hundefreundl. Hotel', color: '#0369a1', z: 20 },
}; };
// Frontend-Layer → Backend-Typ Mapping // Frontend-Layer → Backend-Typ Mapping
@ -109,6 +110,7 @@ window.Page_map = (() => {
parkplatz: 'parkplatz', parkplatz: 'parkplatz',
treffpunkt: 'treffpunkt', treffpunkt: 'treffpunkt',
community: 'sonstiges', community: 'sonstiges',
hotel: 'hotel',
}; };
// Gefahren-Radius-Kreis: prominente rote Fläche // Gefahren-Radius-Kreis: prominente rote Fläche

View file

@ -3,7 +3,7 @@
Offline-Cache + Push Notifications + Tile-Cache Offline-Cache + Push Notifications + Tile-Cache
============================================================ */ ============================================================ */
const CACHE_VERSION = 'by-v873'; const CACHE_VERSION = 'by-v874';
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