From a356626d39495d6d6b2a4de8b545bbb7a9f5c911 Mon Sep 17 00:00:00 2001 From: rene Date: Fri, 29 May 2026 10:32:05 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Pflege-Routinen=20(Zecken-/Flohschut?= =?UTF-8?q?z,=20Krallen,=20Fellpflege)=20=E2=80=94=20neuer=20Pflege-Tab=20?= =?UTF-8?q?mit=20Erledigt+Auto-Wiedervorlage,=20Push-Erinnerungen,=20inter?= =?UTF-8?q?vall=5Ftage-Fix=20im=20INSERT,=20SW=20v1132?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- VERSION | 2 +- backend/routes/health.py | 43 ++++++++-- backend/scheduler.py | 3 +- backend/static/index.html | 24 +++--- backend/static/js/api.js | 1 + backend/static/js/app.js | 2 +- backend/static/js/pages/health.js | 134 +++++++++++++++++++++++++++++- backend/static/landing.html | 2 +- backend/static/sw.js | 2 +- 9 files changed, 187 insertions(+), 26 deletions(-) diff --git a/VERSION b/VERSION index 2d0ce9f..9c6266b 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1131 \ No newline at end of file +1132 \ No newline at end of file diff --git a/backend/routes/health.py b/backend/routes/health.py index 7b9ed35..f16645e 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -15,7 +15,9 @@ MEDIA_DIR = os.getenv("MEDIA_DIR", "/data/media") ALLOWED_EXTENSIONS = {".jpg", ".jpeg", ".png", ".webp", ".pdf"} # Erlaubte Typen -TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", "laeufigkeit"} +# Routine-/Pflege-Typen (wiederkehrend mit intervall_tage): parasit, krallen, fellpflege +TYPEN = {"impfung", "entwurmung", "tierarzt", "medikament", "gewicht", "allergie", "dokument", + "laeufigkeit", "parasit", "krallen", "fellpflege"} # ------------------------------------------------------------------ @@ -164,15 +166,15 @@ async def create_health(dog_id: int, data: HealthCreate, (dog_id, typ, bezeichnung, datum, naechstes, notiz, wert, einheit, charge_nr, tierarzt_name, kosten, diagnose, dosierung, haeufigkeit, aktiv, bis_datum, - schweregrad, reaktion, erinnerung, tierarzt_id, + schweregrad, reaktion, erinnerung, intervall_tage, tierarzt_id, deckdatum, wurftermin) - VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", + VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)""", (dog_id, data.typ, data.bezeichnung, data.datum, data.naechstes, data.notiz, data.wert, data.einheit, data.charge_nr, data.tierarzt_name, data.kosten, data.diagnose, data.dosierung, data.haeufigkeit, data.aktiv, data.bis_datum, - data.schweregrad, data.reaktion, data.erinnerung, data.tierarzt_id, - data.deckdatum, data.wurftermin) + data.schweregrad, data.reaktion, data.erinnerung, data.intervall_tage, + data.tierarzt_id, data.deckdatum, data.wurftermin) ) row = conn.execute( "SELECT * FROM health WHERE dog_id=? ORDER BY id DESC LIMIT 1", @@ -212,6 +214,34 @@ async def update_health(dog_id: int, entry_id: int, data: HealthUpdate, return _entry_with_media(row, media_map) +# ------------------------------------------------------------------ +# POST /api/dogs/{dog_id}/health/{id}/erledigt +# Markiert eine wiederkehrende Routine als heute erledigt und schreibt +# bei gesetztem intervall_tage das nächste Fälligkeitsdatum automatisch fort. +# ------------------------------------------------------------------ +@router.post("/{dog_id}/health/{entry_id}/erledigt") +async def complete_health(dog_id: int, entry_id: int, user=Depends(get_current_user)): + from datetime import timedelta + today = date.today() + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + entry = conn.execute( + "SELECT * FROM health WHERE id=? AND dog_id=?", (entry_id, dog_id) + ).fetchone() + if not entry: + raise HTTPException(404, "Eintrag nicht gefunden.") + + intervall = entry["intervall_tage"] + naechstes = (today + timedelta(days=intervall)).isoformat() if intervall else None + conn.execute( + "UPDATE health SET datum=?, naechstes=? WHERE id=?", + (today.isoformat(), naechstes, entry_id), + ) + row = conn.execute("SELECT * FROM health WHERE id=?", (entry_id,)).fetchone() + media_map = _fetch_media_items(conn, [entry_id]) + return _entry_with_media(row, media_map) + + # ------------------------------------------------------------------ # DELETE /api/dogs/{dog_id}/health/{id} # ------------------------------------------------------------------ @@ -500,6 +530,9 @@ _TERMIN_TYPEN = { 'tierarzt': {'label': 'Tierarztbesuch','beim_tierarzt': True, 'icon': 'first-aid'}, 'medikament': {'label': 'Medikament', 'beim_tierarzt': False, 'icon': 'pill'}, 'laeufigkeit': {'label': 'Läufigkeit', 'beim_tierarzt': False, 'icon': 'calendar'}, + 'parasit': {'label': 'Zecken-/Flohschutz', 'beim_tierarzt': False, 'icon': 'bug-beetle'}, + 'krallen': {'label': 'Krallen schneiden', 'beim_tierarzt': False, 'icon': 'scissors'}, + 'fellpflege': {'label': 'Fellpflege', 'beim_tierarzt': False, 'icon': 'wind'}, } @router.get("/{dog_id}/health/terminvorschlaege") diff --git a/backend/scheduler.py b/backend/scheduler.py index 01c17ae..4f8ce5e 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -638,7 +638,8 @@ async def _job_health_reminders(): FROM health h JOIN dogs d ON d.id = h.dog_id WHERE h.naechstes IN (?, ?, ?, ?) - AND h.typ IN ('impfung', 'entwurmung', 'medikament') + AND h.typ IN ('impfung', 'entwurmung', 'medikament', + 'parasit', 'krallen', 'fellpflege') AND (h.erinnerung IS NULL OR h.erinnerung = 1) """, (str(today), str(in7), str(in3), str(yesterday))).fetchall() diff --git a/backend/static/index.html b/backend/static/index.html index 2cbbd06..45ab38a 100644 --- a/backend/static/index.html +++ b/backend/static/index.html @@ -86,14 +86,14 @@ Ban Yaro - + - - - - - + + + + + @@ -617,11 +617,11 @@ - - - - - + + + + + @@ -631,7 +631,7 @@ - + diff --git a/backend/static/js/api.js b/backend/static/js/api.js index af67c36..de39835 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -204,6 +204,7 @@ const API = (() => { }, create(dogId, data) { return post(`/dogs/${dogId}/health`, data); }, update(dogId, id, d) { return patch(`/dogs/${dogId}/health/${id}`, d); }, + complete(dogId, id) { return post(`/dogs/${dogId}/health/${id}/erledigt`); }, delete(dogId, id) { return del(`/dogs/${dogId}/health/${id}`); }, uploadDokument(dogId, id, formData) { return upload(`/dogs/${dogId}/health/${id}/dokument`, formData); diff --git a/backend/static/js/app.js b/backend/static/js/app.js index bbc25c6..f957ae0 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 = '1131'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '1132'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const APP_VERSION = '1.6.0'; // ← semantische Version, wird bei make release gesetzt window.APP_VER = APP_VER; // global verfügbar für andere Module (z.B. offline-indicator) window.APP_VERSION = APP_VERSION; diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index 78f2430..ff59b94 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -19,6 +19,7 @@ window.Page_health = (() => { { key: 'tierarzt', label: 'Besuche', icon: '' }, { key: 'gewicht', label: 'Gewicht', icon: '' }, { key: 'medikament', label: 'Medikamente', icon: '' }, + { key: 'pflege', label: 'Pflege', icon: '' }, { key: 'allergie', label: 'Allergien', icon: '' }, { key: 'dokument', label: 'Dokumente', icon: '' }, { key: 'praxen', label: 'Praxen', icon: '' }, @@ -27,6 +28,14 @@ window.Page_health = (() => { ]; const LAEUFIGKEIT_TAB = { key: 'laeufigkeit', label: 'Läufigkeit', icon: '' }; + // Pflege-Routinen — wiederkehrende Pflege-Aufgaben, gebündelt im 'pflege'-Tab + const PFLEGE_TYPEN = ['parasit', 'krallen', 'fellpflege']; + const PFLEGE_META = { + parasit: { label: 'Zecken-/Flohschutz', icon: 'bug-beetle', placeholder: 'z.B. Frontline, Seresto-Halsband' }, + krallen: { label: 'Krallen schneiden', icon: 'scissors', placeholder: 'z.B. Krallen kürzen' }, + fellpflege: { label: 'Fellpflege', icon: 'wind', placeholder: 'z.B. Bürsten, Trimmen, Baden' }, + }; + function _getTabs() { const tabs = [...BASE_TABS]; if (_appState?.activeDog?.geschlecht === 'w') tabs.splice(2, 0, LAEUFIGKEIT_TAB); @@ -290,6 +299,8 @@ window.Page_health = (() => { _data = {}; _getTabs().forEach(t => { _data[t.key] = []; }); _data['laeufigkeit'] = _data['laeufigkeit'] || []; + // Pflege-Routinen: eigene Listen je Typ (Tab 'pflege' bündelt sie beim Rendern) + PFLEGE_TYPEN.forEach(t => { _data[t] = []; }); all.forEach(e => { if (_data[e.typ] !== undefined) _data[e.typ].push(e); }); @@ -333,6 +344,7 @@ window.Page_health = (() => { case 'gewicht': content.innerHTML = _renderGewicht(entries); break; case 'laeufigkeit': content.innerHTML = _renderLaeufigkeit(entries); break; case 'medikament': content.innerHTML = _renderMedikamente(entries); break; + case 'pflege': content.innerHTML = _renderPflege(); break; case 'allergie': content.innerHTML = _renderAllergien(entries); break; case 'dokument': content.innerHTML = _renderDokumente(entries); break; case 'praxen': content.innerHTML = _renderPraxen(); break; @@ -410,6 +422,65 @@ window.Page_health = (() => { return { color: 'green', label: 'Aktuell', icon: '🟢' }; } + // ---------------------------------------------------------- + // PFLEGE-ROUTINEN (Zecken-/Flohschutz, Krallen, Fellpflege) + // ---------------------------------------------------------- + function _intervallLabel(tage) { + if (!tage) return ''; + const m = { 30: 'monatlich', 60: 'alle 2 Monate', 90: 'vierteljährlich', 180: 'halbjährlich', 365: 'jährlich' }; + return m[tage] || `alle ${tage} Tage`; + } + + function _renderPflege() { + const addButtons = ` +
+ ${PFLEGE_TYPEN.map(t => ` + `).join('')} +
`; + + const all = PFLEGE_TYPEN.flatMap(t => (_data[t] || []).map(e => ({ ...e, _typ: t }))); + + if (!all.length) return addButtons + _emptyState( + 'paw-print', + 'Noch keine Pflege-Routinen', + 'Lege wiederkehrende Routinen wie Zecken-/Flohschutz, Krallenschneiden oder Fellpflege an — wir erinnern dich rechtzeitig.' + ); + + // Fällige zuerst (nach naechstes), Einträge ohne Folgedatum ans Ende + all.sort((a, b) => { + if (!a.naechstes) return 1; + if (!b.naechstes) return -1; + return a.naechstes.localeCompare(b.naechstes); + }); + + const items = all.map(e => { + const meta = PFLEGE_META[e._typ]; + const ampel = e.naechstes ? _impfAmpel(e.naechstes) : null; + const interv = _intervallLabel(e.intervall_tage); + return ` +
+ ${ampel ? `
` : ''} +
+
${UI.icon(meta.icon)} ${UI.escape(e.bezeichnung || meta.label)}
+
+ ${meta.label}${e.datum ? ` · zuletzt ${UI.time.format(e.datum + 'T00:00:00')}` : ''}${interv ? ` · ${interv}` : ''} +
+ ${e.naechstes ? `
+ Nächste: ${UI.time.format(e.naechstes + 'T00:00:00')} ${ampel.icon} +
` : ''} +
+ +
`; + }).join(''); + + return addButtons + `
${items}
`; + } + // ---------------------------------------------------------- // TIERARZTBESUCHE // ---------------------------------------------------------- @@ -883,14 +954,44 @@ window.Page_health = (() => { // ---------------------------------------------------------- // EVENTS BINDEN // ---------------------------------------------------------- + // Sucht einen Eintrag in der/den Liste(n) des aktiven Tabs. + // Im Pflege-Tab sind die Einträge auf mehrere Typ-Listen verteilt. + function _entriesForActiveTab() { + if (_activeTab === 'pflege') return PFLEGE_TYPEN.flatMap(t => _data[t] || []); + return _data[_activeTab] || []; + } + function _bindTabEvents(content) { content.querySelectorAll('[data-action="add-entry"]').forEach(btn => { btn.addEventListener('click', () => _showForm(null, _activeTab)); }); + // Pflege: pro-Typ-Button "+ Routine" → Formular mit festem Typ + content.querySelectorAll('[data-action="add-routine"]').forEach(btn => { + btn.addEventListener('click', () => _showForm(null, btn.dataset.typ)); + }); + // Pflege: Routine als erledigt markieren → Backend schreibt naechstes fort + content.querySelectorAll('[data-action="routine-erledigt"]').forEach(btn => { + btn.addEventListener('click', async e => { + e.stopPropagation(); + const id = parseInt(btn.dataset.id); + await UI.asyncButton(btn, async () => { + const saved = await API.health.complete(_appState.activeDog.id, id); + const list = _data[saved.typ]; + if (list) { + const idx = list.findIndex(x => x.id === id); + if (idx !== -1) list[idx] = saved; + } + _renderTab(); + _renderErinnerungen(); + UI.toast.success('Als erledigt eingetragen.'); + }); + }); + }); content.querySelectorAll('[data-action="open-entry"]').forEach(card => { const id = parseInt(card.dataset.id); - const entry = (_data[_activeTab] || []).find(e => e.id === id); - if (entry) card.addEventListener('click', () => _openDetail(entry)); + const entry = _entriesForActiveTab().find(e => e.id === id); + if (entry) card.addEventListener('click', () => + _activeTab === 'pflege' ? _showForm(entry, entry.typ) : _openDetail(entry)); }); content.querySelectorAll('[data-action="open-note"]').forEach(btn => { btn.addEventListener('click', e => { @@ -980,8 +1081,19 @@ window.Page_health = (() => { // ---------------------------------------------------------- // DETAIL-ANSICHT // ---------------------------------------------------------- + // Tab-Info (Icon + Label) für einen Typ — kennt auch die Pflege-Routine-Typen, + // die keinen eigenen Tab haben (sie liegen im gebündelten 'pflege'-Tab). + function _typInfo(typ) { + const meta = PFLEGE_META[typ]; + if (meta) return { + icon: ``, + label: meta.label, + }; + return _getTabs().find(t => t.key === typ) || BASE_TABS[0]; + } + function _openDetail(entry) { - const tabInfo = _getTabs().find(t => t.key === entry.typ) || BASE_TABS[0]; + const tabInfo = _typInfo(entry.typ); const fields = _detailFields(entry); // Media-Items zusammenstellen (neue + legacy) @@ -1151,7 +1263,7 @@ window.Page_health = (() => { `; - const tabInfo = _getTabs().find(tab => tab.key === t) || BASE_TABS[0]; + const tabInfo = _typInfo(t); UI.modal.open({ title: `${tabInfo.icon} ${isEdit ? 'Bearbeiten' : tabInfo.label}`, body, footer }); const form = document.getElementById('health-form'); @@ -1294,6 +1406,9 @@ window.Page_health = (() => { allergie: 'z.B. Hühnchen, Gras, Hausstaub', dokument: 'z.B. Impfpass, Blutbild', laeufigkeit: 'Läufigkeit', + parasit: 'z.B. Frontline, Seresto-Halsband', + krallen: 'z.B. Krallen kürzen', + fellpflege: 'z.B. Bürsten, Trimmen, Baden', }; return ph[typ] || ''; } @@ -1363,6 +1478,17 @@ window.Page_health = (() => { ${_praxisSelectField(entry)} `; + case 'parasit': + case 'krallen': + case 'fellpflege': return ` +
+
+ + +
+ ${_intervallField(entry)} +
+ `; case 'tierarzt': { const aktivePraxen = _praxen.filter(p => p.aktiv); const praxisField = aktivePraxen.length diff --git a/backend/static/landing.html b/backend/static/landing.html index a4d6f80..0bc6044 100644 --- a/backend/static/landing.html +++ b/backend/static/landing.html @@ -4,7 +4,7 @@ - + Ban Yaro — Die Hunde-App für Deutschland, Österreich & Schweiz diff --git a/backend/static/sw.js b/backend/static/sw.js index ac211d5..ded7238 100644 --- a/backend/static/sw.js +++ b/backend/static/sw.js @@ -4,7 +4,7 @@ ============================================================ */ // ← EINZIGE Stelle für die Version — STATIC_ASSETS und CACHE_VERSION leiten sich ab -const VER = '1131'; +const VER = '1132'; const CACHE_VERSION = `by-v${VER}`; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten