diff --git a/backend/database.py b/backend/database.py index ab82594..50f50ee 100644 --- a/backend/database.py +++ b/backend/database.py @@ -580,6 +580,8 @@ def _migrate(conn_factory): ("users", "password_reset_expires", "TEXT"), # Fell-Typ für personalisierte Wetter-Hinweise ("dogs", "fell_typ", "TEXT"), # kurz|mittel|lang|drahtaar|doppel|nackt + # Widerristhöhe in cm (höchster Punkt Schulterblatt → Boden) + ("dogs", "widerrist_cm", "REAL"), # Tierarzt-Bewertungen: Durchschnitt + Anzahl am Tierarzt-Datensatz ("tieraerzte", "avg_rating", "REAL DEFAULT 0"), ("tieraerzte", "anz_bewertungen", "INTEGER DEFAULT 0"), @@ -2208,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 dc86828..ea20480 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 = "872" # muss mit APP_VER in app.js übereinstimmen +APP_VER = "875" # muss mit APP_VER in app.js übereinstimmen @app.get("/.well-known/assetlinks.json") async def assetlinks(): @@ -1407,6 +1407,7 @@ async def ausweis_page(dog_id: int, request: Request):
Geschlecht
{geschlecht}
Gewicht
{f'{dog["gewicht_kg"]} kg' if dog.get("gewicht_kg") else "–"}
Transponder
{esc(dog.get("chip_nr")) or "–"}
+ {f'
Widerrist
{dog["widerrist_cm"]} cm
' if dog.get("widerrist_cm") else ''}
Besitzer
{esc(owner["name"]) if owner else "–"}
@@ -1414,7 +1415,7 @@ async def ausweis_page(dog_id: int, request: Request):
-
` : ''} + ${dog.widerrist_cm ? ` +
+
Widerrist
+
${dog.widerrist_cm} cm
+
+ ` : ''}
@@ -1133,6 +1139,18 @@ window.Page_dog_profile = (() => { value="${dog?.gewicht_kg || ''}" min="0.1" max="120" step="0.1" placeholder="z. B. 28.5">
+
+ + +
+
+ +
+
@@ -1327,8 +1346,9 @@ window.Page_dog_profile = (() => { rasse_id: fd.rasse_id ? parseInt(fd.rasse_id) : null, geburtstag: fd.geburtstag || null, geschlecht: fd.geschlecht || null, - gewicht_kg: fd.gewicht_kg ? parseFloat(fd.gewicht_kg) : null, - chip_nr: fd.chip_nr || null, + gewicht_kg: fd.gewicht_kg ? parseFloat(fd.gewicht_kg) : null, + widerrist_cm: fd.widerrist_cm ? parseFloat(fd.widerrist_cm) : null, + chip_nr: fd.chip_nr || null, bio: fd.bio || null, is_public: 'is_public' in fd, fell_typ: fd.fell_typ || null, 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 ` +
+ +
+ ${_esc(r.bezeichnung)} + ${TYPE_LABEL[r.typ] || r.typ} +
+ ${label} +
`; + }).join(''); + } + + // ============================================================== + // TAB: VERSICHERUNG + // ============================================================== + async function _renderVersicherung(content) { + const dog = _appState?.activeDog; + if (!dog) return; + content.innerHTML = `
+
+ +
`; + + let policies; + try { policies = await API.health.insuranceList(dog.id); } + catch { content.innerHTML = `

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 => ` +
+
+
+
${_esc(p.anbieter)}
+ ${p.police_nr ? `
Police: ${_esc(p.police_nr)}
` : ''} +
+
+ + +
+
+
+
Jahresbeitrag
${_fmtEur(p.jahresbeitrag)}
+
Läuft ab
${_fmtDate(p.ablaufdatum)}
+ ${p.kontakt ? `
Kontakt
${_esc(p.kontakt)}
` : ''} + ${p.notizen ? `
Notizen
${_esc(p.notizen)}
` : ''} +
+
`).join('') : ` +
+ +
Noch keine Versicherung eingetragen.
+
`; + + content.innerHTML = `
+ ${cardsHtml} + +
`; + + 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 = `
+
+ +
+
+ +
+
+
+ +
+
+ +
+
+
+ +
+
+ +
+
`; + const footer = ` + + `; + 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 = `
+
+ +
`; + + let resp; + try { resp = await API.health.behaviorList(dog.id); } + catch { content.innerHTML = `

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 ` +
+
+
+
+ ${_esc(katLabel)} + ${trigLabel ? `${_esc(trigLabel)}` : ''} + ${fmtDate(e.datum)}${e.uhrzeit ? ' ' + e.uhrzeit : ''} +
+
${dots}
+ ${e.notiz ? `
${_esc(e.notiz)}
` : ''} +
+ +
`; + }).join('') : ` +
+ +
Noch keine Einträge. Protokolliere auffälliges Verhalten um Muster zu erkennen.
+
`; + + content.innerHTML = `
+ ${listHtml} + +
`; + + 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 = `
+
+
+ +
+
+ +
+
+
+ +
+
+
+ ${[1,2,3,4,5].map(n => ``).join('')} +
+ +
+
+ +
+
+ +
+
`; + const footer = ` + + `; + 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 }; })(); diff --git a/backend/static/js/pages/map.js b/backend/static/js/pages/map.js index de58da5..dda7473 100644 --- a/backend/static/js/pages/map.js +++ b/backend/static/js/pages/map.js @@ -75,7 +75,7 @@ window.Page_map = (() => { // z: zIndexOffset — höher = weiter oben bei Überlappung const TYPEN = { - restaurant: { icon: '', label: 'Restaurant', color: '#F97316', z: 10 }, + restaurant: { icon: '', label: 'Hundefreundl. Café/Restaurant', color: '#F97316', z: 10 }, freilauf: { icon: '', label: 'Freilauf', color: '#22C55E', z: 20 }, shop: { icon: '', label: 'Shop', color: '#3B82F6', z: 15 }, kotbeutel: { icon: '', label: 'Kotbeutel', color: '#84A98C', z: 5 }, @@ -92,6 +92,7 @@ window.Page_map = (() => { treffpunkt: { icon: '', label: 'Treffpunkt', color: '#7C3AED', z: 25 }, community: { icon: '', label: 'Sonstiges', color: '#F59E0B', z: 30 }, zuechter: { icon: '', label: 'Züchter', color: '#7C3AED', z: 50 }, + hotel: { icon: '', 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 diff --git a/backend/static/sw.js b/backend/static/sw.js index e9b1388..797d5b7 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -3,7 +3,7 @@ Offline-Cache + Push Notifications + Tile-Cache ============================================================ */ -const CACHE_VERSION = 'by-v872'; +const CACHE_VERSION = 'by-v875'; 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