diff --git a/backend/database.py b/backend/database.py index 5147c95..50f50ee 100644 --- a/backend/database.py +++ b/backend/database.py @@ -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] diff --git a/backend/main.py b/backend/main.py index 23bd67c..5c9ff53 100644 --- a/backend/main.py +++ b/backend/main.py @@ -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(): diff --git a/backend/routes/health.py b/backend/routes/health.py index f803b07..34ec5ec 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -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 diff --git a/backend/routes/osm.py b/backend/routes/osm.py index 998cd4b..f58fd4c 100644 --- a/backend/routes/osm.py +++ b/backend/routes/osm.py @@ -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 diff --git a/backend/scheduler.py b/backend/scheduler.py index f0487c8..548e4c9 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -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." diff --git a/backend/static/index.html b/backend/static/index.html index 21bc8d6..c41d071 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -101,9 +101,9 @@ - - - + + +
@@ -583,10 +583,10 @@ - - - - + + + + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 5781a62..b383d66 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -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}`); }, }; // ---------------------------------------------------------- diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bb6051a..70eaf72 100644 --- a/backend/static/js/app.js +++ b/backend/static/js/app.js @@ -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 diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index b984515..bf68615 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -22,6 +22,8 @@ window.Page_health = (() => { { key: 'allergie', label: 'Allergien', icon: '' }, { key: 'dokument', label: 'Dokumente', icon: '' }, { key: 'praxen', label: 'Praxen', icon: '' }, + { key: 'versicherung', label: 'Versicherung', icon: '' }, + { key: 'verhalten', label: 'Verhalten', icon: '' }, ]; const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '' }; @@ -111,12 +113,14 @@ window.Page_health = (() => { + `; _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 ` +Fehler beim Laden.
`; 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 => ` +Fehler beim Laden.
`; 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) => + `` + ).join(''); + return ` +