diff --git a/backend/ki.py b/backend/ki.py index d82522e..ab776ef 100644 --- a/backend/ki.py +++ b/backend/ki.py @@ -90,6 +90,15 @@ def _check_weekly_cloud_limit(user_id: int | None) -> None: try: from database import db with db() as conn: + user = conn.execute( + "SELECT rolle, is_moderator FROM users WHERE id=?", (user_id,) + ).fetchone() + # Admins, Moderatoren und Media Manager haben kein Limit + if user and ( + user["rolle"] in ("admin", "moderator", "media_manager") + or user["is_moderator"] + ): + return used = conn.execute( """SELECT COALESCE(SUM(count), 0) FROM ki_daily_calls WHERE user_id=? AND source='cloud' diff --git a/backend/routes/health.py b/backend/routes/health.py index 0de348b..bad2d01 100644 --- a/backend/routes/health.py +++ b/backend/routes/health.py @@ -467,3 +467,84 @@ async def list_ki_berichte(dog_id: int, user=Depends(get_current_user)): (dog_id,) ).fetchall() return [dict(r) for r in rows] + + +# ------------------------------------------------------------------ +# GET /api/dogs/{dog_id}/health/terminvorschlaege +# Gibt strukturierte Termin-Vorschläge auf Basis fälliger health-Einträge. +# ------------------------------------------------------------------ +_TERMIN_TYPEN = { + 'impfung': {'label': 'Impfung', 'beim_tierarzt': True, 'icon': 'syringe'}, + 'entwurmung': {'label': 'Entwurmung', 'beim_tierarzt': False, 'icon': 'pill'}, + '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'}, +} + +@router.get("/{dog_id}/health/terminvorschlaege") +async def terminvorschlaege(dog_id: int, user=Depends(get_current_user)): + from timeutils import next_appointment_slot + from datetime import date, timedelta + + today = date.today() + horizon = today + timedelta(days=30) + + with db() as conn: + _check_dog_owner(conn, dog_id, user["id"]) + + # Einträge mit fälligem naechstes (überfällig oder in 30 Tagen) + rows = conn.execute( + """SELECT id, typ, bezeichnung, naechstes, tierarzt_id + FROM health + WHERE dog_id=? AND naechstes IS NOT NULL + AND naechstes <= ? AND aktiv=1 + ORDER BY naechstes ASC""", + (dog_id, horizon.isoformat()) + ).fetchall() + + # Primäre Praxis des Users (erste aktive) + praxis = conn.execute( + "SELECT name, opening_hours, lat, lon FROM tieraerzte " + "WHERE user_id=? AND aktiv=1 ORDER BY id LIMIT 1", + (user["id"],) + ).fetchone() + + oh = praxis["opening_hours"] if praxis else None + praxis_name = praxis["name"] if praxis else None + praxis_lat = praxis["lat"] if praxis else None + praxis_lon = praxis["lon"] if praxis else None + + vorschlaege = [] + for r in rows: + cfg = _TERMIN_TYPEN.get(r["typ"]) + if not cfg: + continue + + naechstes = date.fromisoformat(r["naechstes"]) + ueberfaellig = naechstes < today + delta_tage = (naechstes - today).days + + # Terminfindung: bei Tierarzt-Typen Öffnungszeiten nutzen + slot_oh = oh if cfg["beim_tierarzt"] else None + # Frühestens ab morgen, aber nicht vor dem Fälligkeitsdatum wenn noch in der Zukunft + start = today if ueberfaellig else naechstes - timedelta(days=1) + datum_v, uhrzeit_v = next_appointment_slot(slot_oh, start_from=start) + + vorschlaege.append({ + "health_id": r["id"], + "typ": r["typ"], + "label": cfg["label"], + "icon": cfg["icon"], + "bezeichnung": r["bezeichnung"], + "naechstes": r["naechstes"], + "ueberfaellig": ueberfaellig, + "delta_tage": delta_tage, + "beim_tierarzt": cfg["beim_tierarzt"], + "datum_vorschlag": datum_v, + "uhrzeit_vorschlag": uhrzeit_v, + "praxis_name": praxis_name if cfg["beim_tierarzt"] else None, + "praxis_lat": praxis_lat if cfg["beim_tierarzt"] else None, + "praxis_lon": praxis_lon if cfg["beim_tierarzt"] else None, + }) + + return vorschlaege diff --git a/backend/static/js/api.js b/backend/static/js/api.js index 39c423e..a48474f 100644 --- a/backend/static/js/api.js +++ b/backend/static/js/api.js @@ -168,7 +168,8 @@ const API = (() => { kiZusammenfassung(dogId) { return post(`/dogs/${dogId}/health/ki-zusammenfassung`); }, - kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); }, + kiBerichte(dogId) { return get(`/dogs/${dogId}/health/ki-berichte`); }, + terminvorschlaege(dogId) { return get(`/dogs/${dogId}/health/terminvorschlaege`); }, symptomCheck(dogId, symptoms) { return post(`/dogs/${dogId}/health/symptom-check`, { symptoms }); }, diff --git a/backend/static/js/app.js b/backend/static/js/app.js index 7bd4f37..4826d6d 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 = '413'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen +const APP_VER = '414'; // ← bei jedem Deploy mit Frontend-Änderungen erhöhen const App = (() => { diff --git a/backend/static/js/pages/health.js b/backend/static/js/pages/health.js index aef0c03..413df57 100644 --- a/backend/static/js/pages/health.js +++ b/backend/static/js/pages/health.js @@ -153,6 +153,7 @@ window.Page_health = (() => { ${transponderHtml}
+
@@ -168,6 +169,7 @@ window.Page_health = (() => { _renderErinnerungen(); _renderTab(); _loadKiBerichte(dog.id); + _loadTerminvorschlaege(dog.id); } // ---------------------------------------------------------- @@ -1990,6 +1992,126 @@ window.Page_health = (() => { // ---------------------------------------------------------- // KI-ZUSAMMENFASSUNG + // ---------------------------------------------------------- + // TERMINVORSCHLÄGE + // ---------------------------------------------------------- + async function _loadTerminvorschlaege(dogId) { + const el = _container.querySelector('#health-terminvorschlaege'); + if (!el) return; + try { + const vorschlaege = await API.health.terminvorschlaege(dogId); + if (!vorschlaege || !vorschlaege.length) return; + + const _fmtDatum = iso => new Date(iso + 'T00:00:00').toLocaleDateString('de-DE', { + weekday: 'short', day: '2-digit', month: '2-digit', year: 'numeric' + }); + + el.innerHTML = ` +
+
+ Terminvorschläge +
+
+ ${vorschlaege.map(v => { + const badge = v.ueberfaellig + ? `Überfällig seit ${_fmtDatum(v.naechstes)}` + : `Fällig am ${_fmtDatum(v.naechstes)}`; + return ` +
+
+
${_esc(v.bezeichnung)}
+
${_esc(v.label)}${v.praxis_name ? ' · ' + _esc(v.praxis_name) : ''}
+ ${badge} +
+
+
Vorschlag
+
${_fmtDatum(v.datum_vorschlag)}
+
${v.uhrzeit_vorschlag} Uhr
+ +
+
+ `; + }).join('')} +
+
+ `; + + el.querySelectorAll('[data-action="termin-anlegen"]').forEach(btn => { + btn.addEventListener('click', async () => { + let v; + try { v = JSON.parse(btn.dataset.v); } catch { return; } + await _terminAnlegen(v, btn); + }); + }); + } catch { /* still show health page if this fails */ } + } + + async function _terminAnlegen(v, btn) { + const titel = v.beim_tierarzt + ? `${v.label}: ${v.bezeichnung} (Tierarzt)` + : `${v.label}: ${v.bezeichnung}`; + const beschreibung = v.praxis_name + ? `Praxis: ${v.praxis_name}` + : v.ueberfaellig + ? `Überfällig seit ${v.naechstes}` + : `Fällig am ${v.naechstes}`; + + UI.modal.open({ + title: '📅 Termin in Kalender eintragen', + body: ` +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ `, + footer: ` + + + `, + }); + document.getElementById('termin-cancel')?.addEventListener('click', UI.modal.close); + document.getElementById('termin-form')?.addEventListener('submit', async e => { + e.preventDefault(); + const saveBtn = document.querySelector('[form="termin-form"][type="submit"]'); + const fd = UI.formData(e.target); + await UI.asyncButton(saveBtn, async () => { + await API.events.create({ + titel: fd.titel, + datum: fd.datum, + uhrzeit: fd.uhrzeit || null, + beschreibung: fd.beschreibung || null, + typ: v.beim_tierarzt ? 'tierarzt' : 'sonstiges', + lat: v.praxis_lat ?? null, + lon: v.praxis_lon ?? null, + ort_name: v.praxis_name ?? null, + }); + UI.modal.close(); + UI.toast.success('Termin gespeichert — erscheint in deinem Kalender.'); + }); + }); + } + // ---------------------------------------------------------- async function _showKiSummary() { const btn = _container.querySelector('#health-ki-btn'); diff --git a/backend/static/sw.js b/backend/static/sw.js index 29b4e39..8cc2185 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-v434'; +const CACHE_VERSION = 'by-v435'; const CACHE_STATIC = `${CACHE_VERSION}-static`; const CACHE_TILES = 'ban-yaro-tiles-v1'; // bleibt über SW-Updates erhalten diff --git a/backend/timeutils.py b/backend/timeutils.py index 9c1821b..05271ae 100644 --- a/backend/timeutils.py +++ b/backend/timeutils.py @@ -1,15 +1,93 @@ -"""Hilfsfunktionen für client-seitige Zeitstempel.""" +"""Hilfsfunktionen für client-seitige Zeitstempel und Öffnungszeiten.""" import re -from datetime import datetime +from datetime import datetime, date, timedelta def safe_client_time(client_time: str | None) -> str: - """Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now. - - Schützt gegen Injection: nur YYYY-MM-DD HH:MM[:SS] erlaubt. - """ + """Gibt client_time zurück falls valides ISO-Datetime, sonst UTC-Now.""" if client_time and re.match( r'^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?$', client_time ): return client_time.replace('T', ' ')[:19] return datetime.utcnow().strftime("%Y-%m-%d %H:%M:%S") + + +# OSM-Wochentag-Kürzel → Python-weekday (Mo=0 … So=6) +_DAY = {'Mo': 0, 'Di': 1, 'Mi': 2, 'Do': 3, 'Fr': 4, 'Sa': 5, 'So': 6} +_DAY_EN = {'Mo': 0, 'Tu': 1, 'We': 2, 'Th': 3, 'Fr': 4, 'Sa': 5, 'Su': 6} + + +def _parse_oh_segments(oh: str) -> list[tuple[set[int], str, str]]: + """Parst OSM-opening_hours in [(weekdays, open_time, close_time), …]. + + Unterstützt: "Mo-Fr 08:00-18:00; Sa 09:00-13:00", "24/7", "Mo,Mi 09:00-17:00" + """ + if not oh: + return [] + oh = oh.strip() + if oh.lower() in ('24/7', '24/7 open'): + return [({0, 1, 2, 3, 4, 5, 6}, '09:00', '18:00')] + + segments = [] + for part in oh.split(';'): + part = part.strip() + m = re.match( + r'^([A-Za-z,\-]+)\s+(\d{2}:\d{2})-(\d{2}:\d{2})$', part + ) + if not m: + continue + day_spec, open_t, close_t = m.group(1), m.group(2), m.group(3) + days: set[int] = set() + for chunk in day_spec.split(','): + chunk = chunk.strip() + if '-' in chunk: + parts = chunk.split('-') + if len(parts) == 2: + d1 = _DAY.get(parts[0], _DAY_EN.get(parts[0])) + d2 = _DAY.get(parts[1], _DAY_EN.get(parts[1])) + if d1 is not None and d2 is not None: + days.update(range(d1, d2 + 1)) + else: + d = _DAY.get(chunk, _DAY_EN.get(chunk)) + if d is not None: + days.add(d) + if days: + segments.append((days, open_t, close_t)) + return segments + + +def next_appointment_slot( + opening_hours: str | None, + start_from: date | None = None, + prefer_morning: bool = True, +) -> tuple[str, str]: + """Gibt (datum_str, uhrzeit_str) für den nächsten Slot zurück. + + Sucht ab morgen (oder start_from) innerhalb der Öffnungszeiten. + Fallback: nächster Werktag 09:00 wenn keine Öffnungszeiten vorhanden. + Uhrzeit = Öffnungszeit + 1h (Puffer), min. 09:00, max. 11:00 (Vormittag). + """ + base = (start_from or date.today()) + timedelta(days=1) + segments = _parse_oh_segments(opening_hours or '') + + if not segments: + # Fallback: nächster Mo–Fr 09:00 + d = base + for _ in range(14): + if d.weekday() < 5: # Mo–Fr + return d.isoformat(), '09:00' + d += timedelta(days=1) + return base.isoformat(), '09:00' + + for _ in range(14): + wd = base.weekday() + for days, open_t, close_t in segments: + if wd in days: + # Slot: Öffnungszeit + 1h, mindestens 09:00, höchstens 11:00 + h, m = map(int, open_t.split(':')) + slot_h = max(9, h + 1) + slot_h = min(slot_h, 11) + return base.isoformat(), f'{slot_h:02d}:00' + base += timedelta(days=1) + + return base.isoformat(), '09:00'