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:
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
try:
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:
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")
async def assetlinks():

View file

@ -569,3 +569,190 @@ async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)):
})
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;',
'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;',
'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;',
'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

View file

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

View file

@ -101,9 +101,9 @@
</script>
<!-- CSS: Reihenfolge ist wichtig — ?v= zwingt Browser zur Neuladung -->
<link rel="stylesheet" href="/css/design-system.css?v=873">
<link rel="stylesheet" href="/css/layout.css?v=873">
<link rel="stylesheet" href="/css/components.css?v=873">
<link rel="stylesheet" href="/css/design-system.css?v=874">
<link rel="stylesheet" href="/css/layout.css?v=874">
<link rel="stylesheet" href="/css/components.css?v=874">
</head>
<body>
@ -583,10 +583,10 @@
<div id="modal-container"></div>
<!-- JS: Reihenfolge ist wichtig — erst Basis, dann Features -->
<script src="/js/api.js?v=873"></script>
<script src="/js/ui.js?v=873"></script>
<script src="/js/app.js?v=873"></script>
<script src="/js/worlds.js?v=873"></script>
<script src="/js/api.js?v=874"></script>
<script src="/js/ui.js?v=874"></script>
<script src="/js/app.js?v=874"></script>
<script src="/js/worlds.js?v=874"></script>
<!-- Feature-Seiten werden lazy geladen -->

View file

@ -219,6 +219,14 @@ const API = (() => {
gewichtVerlauf(dogId) {
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.
============================================================ */
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 IS_STAGING = location.hostname === 'staging.banyaro.app';
// 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: '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: '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>' };
@ -111,12 +113,14 @@ window.Page_health = (() => {
<div id="health-ki-berichte"></div>
<div id="health-terminvorschlaege"></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 id="by-tab-content"></div>
`;
_renderTabBar();
UI.bindDogChip(_container, _appState);
_loadRemindersBanner();
_container.querySelector('#health-ki-btn')
.addEventListener('click', _showKiSummary);
_container.querySelector('#health-ki-tierarzt-btn')
@ -332,6 +336,8 @@ window.Page_health = (() => {
case 'allergie': content.innerHTML = _renderAllergien(entries); break;
case 'dokument': content.innerHTML = _renderDokumente(entries); break;
case 'praxen': content.innerHTML = _renderPraxen(); break;
case 'versicherung': _renderVersicherung(content); break;
case 'verhalten': _renderVerhalten(content); break;
}
_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 };
})();

View file

@ -75,7 +75,7 @@ window.Page_map = (() => {
// z: zIndexOffset — höher = weiter oben bei Überlappung
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 },
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 },
@ -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 },
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 },
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
@ -109,6 +110,7 @@ window.Page_map = (() => {
parkplatz: 'parkplatz',
treffpunkt: 'treffpunkt',
community: 'sonstiges',
hotel: 'hotel',
};
// Gefahren-Radius-Kreis: prominente rote Fläche

View file

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