diff --git a/backend/routes/dogs.py b/backend/routes/dogs.py index a44faa0..f94258f 100644 --- a/backend/routes/dogs.py +++ b/backend/routes/dogs.py @@ -181,18 +181,29 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): raise HTTPException(404, "Hund nicht gefunden.") # Zufälliges Foto aus den letzten 100 Tagebuchbildern + # Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge photos = conn.execute( """SELECT dm.url FROM diary_media dm JOIN diary d ON d.id = dm.diary_id WHERE d.dog_id=? AND dm.media_type='image' - ORDER BY d.datum DESC LIMIT 100""", + AND dm.img_width IS NOT NULL AND dm.img_width > dm.img_height + ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", (dog_id,) ).fetchall() + # Fallback: alle Fotos ohne Maß-Filter (Bilder vor dem Backfill) + if not photos: + photos = conn.execute( + """SELECT dm.url FROM diary_media dm + JOIN diary d ON d.id = dm.diary_id + WHERE d.dog_id=? AND dm.media_type='image' + ORDER BY d.datum DESC, d.id DESC, dm.id ASC""", + (dog_id,) + ).fetchall() random_photo = None if photos: import datetime as _dt2 - day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days - chosen_url = photos[day_num % len(photos)]["url"] + tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days + chosen_url = photos[tick % len(photos)]["url"] random_photo = { "url": chosen_url, "preview_url": preview_url_from(chosen_url), @@ -294,6 +305,137 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)): } +@router.get("/{dog_id}/wrapped") +async def get_dog_wrapped(dog_id: int, year: int = None, user=Depends(get_current_user)): + """Jahresrückblick ('Wrapped') für einen Hund.""" + import json as _json + from datetime import date as _date + + if year is None: + year = _date.today().year + + with db() as conn: + dog = conn.execute( + "SELECT id, name, user_id FROM dogs WHERE id=? AND user_id=?", + (dog_id, user["id"]) + ).fetchone() + if not dog: + raise HTTPException(404, "Hund nicht gefunden.") + + # km gelaufen (eigene Routen des Users) + gesamt_km_row = conn.execute( + "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y', created_at)=?", + (user["id"], str(year)) + ).fetchone() + gesamt_km = gesamt_km_row["km"] or 0.0 + + # Gassi-Tage (Distinct Datum in Diary) + gassi_tage = conn.execute( + "SELECT COUNT(DISTINCT datum) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Gesamte Einträge + eintraege_gesamt = conn.execute( + "SELECT COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Fotos gesamt + fotos_gesamt = conn.execute( + "SELECT COUNT(*) AS n FROM diary_media dm " + "JOIN diary d ON d.id=dm.diary_id " + "WHERE d.dog_id=? AND strftime('%Y', d.datum)=? AND dm.media_type='image'", + (dog_id, str(year)) + ).fetchone()["n"] + + # Beste Route (längste distanz) + beste_route_row = conn.execute( + "SELECT MAX(distanz_km) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y', created_at)=?", + (user["id"], str(year)) + ).fetchone() + beste_route = beste_route_row["km"] or 0.0 + + # Lieblingsmonat (meiste diary-Einträge) + monat_rows = conn.execute( + "SELECT strftime('%m', datum) AS monat, COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? " + "GROUP BY monat ORDER BY n DESC LIMIT 1", + (dog_id, str(year)) + ).fetchone() + lieblings_monat = None + if monat_rows: + _MONATE = ['Jan','Feb','Mär','Apr','Mai','Jun','Jul','Aug','Sep','Okt','Nov','Dez'] + try: + lieblings_monat = _MONATE[int(monat_rows["monat"]) - 1] + except Exception: + pass + + # Lieblingsaktivität (häufigster typ) + typ_row = conn.execute( + "SELECT typ, COUNT(*) AS n FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? " + "GROUP BY typ ORDER BY n DESC LIMIT 1", + (dog_id, str(year)) + ).fetchone() + lieblings_aktivitaet = typ_row["typ"] if typ_row else None + + # Training-Sessions + training_sessions = conn.execute( + "SELECT COUNT(*) AS n FROM training_sessions " + "WHERE dog_id=? AND strftime('%Y', created_at)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Gesundheits-Einträge + gesundheit_eintraege = conn.execute( + "SELECT COUNT(*) AS n FROM health " + "WHERE dog_id=? AND strftime('%Y', datum)=?", + (dog_id, str(year)) + ).fetchone()["n"] + + # Wetter-Tapferkeit: Tagebuch-Einträge mit weather_json + wetter_kalt = 0 + wetter_warm = 0 + wetter_rows = conn.execute( + "SELECT weather_json FROM diary " + "WHERE dog_id=? AND strftime('%Y', datum)=? AND weather_json IS NOT NULL", + (dog_id, str(year)) + ).fetchall() + for wr in wetter_rows: + try: + wj = _json.loads(wr["weather_json"]) + temp = wj.get("temp_c") or wj.get("temperature") or wj.get("temp") + if temp is not None: + if float(temp) < 5: + wetter_kalt += 1 + elif float(temp) > 25: + wetter_warm += 1 + except Exception: + pass + + return { + "dog_id": dog_id, + "dog_name": dog["name"], + "year": year, + "gesamt_km": gesamt_km, + "gassi_tage": gassi_tage, + "eintraege_gesamt": eintraege_gesamt, + "fotos_gesamt": fotos_gesamt, + "beste_route": beste_route, + "lieblings_monat": lieblings_monat, + "lieblings_aktivitaet": lieblings_aktivitaet, + "training_sessions": training_sessions, + "gesundheit_eintraege": gesundheit_eintraege, + "wetter_kalt": wetter_kalt, + "wetter_warm": wetter_warm, + } + + @router.get("/{dog_id}") async def get_dog(dog_id: int, user=Depends(get_current_user)): with db() as conn: diff --git a/backend/scheduler.py b/backend/scheduler.py index 9c2f07c..4d1dbff 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -164,8 +164,24 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 09:00 Uhr — Jahrestags-Erinnerungen (Tagebuch-Einträge von heute vor X Jahren) + _scheduler.add_job( + _job_anniversary_reminders, + CronTrigger(hour=9, minute=0), + id="anniversary_reminders", + replace_existing=True, + misfire_grace_time=3600, + ) + # 1. des Monats 10:00 — Monatlicher Rückblick per Push + _scheduler.add_job( + _job_monthly_recap, + CronTrigger(day=1, hour=10, minute=0), + id="monthly_recap", + replace_existing=True, + misfire_grace_time=3600, + ) _scheduler.start() - logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00. OSM-Cache: on-demand (kein Prewarm).") + logger.info("Scheduler gestartet — Health-Reminder 08:00, Giftköder-Archiv 03:00, Wetter-Alert 07:30, Meilenstein-Check 00:05, Event-Import So 02:00, Rassen-Seed monatlich 1. des Monats, Status-Report täglich 06:00, Moderation-Overdue 12:00, Quartalsbericht 1. Feb/Mai/Aug/Nov 07:00, Streak-Reminder 19:00, Rückruf-Check 08:00, Goldene-Gassi-Stunde 07:00, Jahrestags-Erinnerungen 09:00, Monatlicher-Rückblick 1. des Monats 10:00. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -890,6 +906,8 @@ async def _job_status_report(): "streak_reminder": "Streak-Erinnerung (täglich 19:00)", "recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)", "golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07:00)", + "anniversary_reminders": "Jahrestags-Erinnerungen (täglich 09:00)", + "monthly_recap": "Monatlicher Rückblick (1. des Monats 10:00)", } job_rows_html = "" job_rows_txt = "" @@ -1383,6 +1401,149 @@ async def _job_golden_gassi_hour(): _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User") +# ------------------------------------------------------------------ +# JOB: Jahrestags-Erinnerungen (täglich 09:00) +# ------------------------------------------------------------------ +async def _job_anniversary_reminders(): + """Prüft ob heute ein Jahrestag für diary-Einträge vorliegt und sendet Push.""" + today = datetime.now(tz=_TZ) + today_md = today.strftime('%m-%d') # Monat-Tag ohne Jahr + + logger.info(f"Jahrestags-Erinnerungen Job läuft für {today_md}") + + with db() as conn: + entries = conn.execute(""" + SELECT d.id, d.titel, d.datum, d.user_id, d.dog_id, + (SELECT dm.url FROM diary_media dm + WHERE dm.diary_id=d.id LIMIT 1) AS foto_url + FROM diary d + WHERE strftime('%m-%d', d.datum) = ? + AND d.datum < date('now') + AND d.titel IS NOT NULL + AND d.is_milestone = 0 + """, (today_md,)).fetchall() + + sent_total = 0 + for e in entries: + try: + jahre = today.year - int(e['datum'][:4]) + if jahre < 1: + continue + jahre_label = f"{jahre} Jahr" if jahre == 1 else f"{jahre} Jahren" + send_push_to_user(e['user_id'], { + 'type': 'anniversary_reminder', + 'title': f'📅 Vor {jahre_label}: {(e["titel"] or "")[:40]}', + 'body': 'Erinnerung an diesen besonderen Tag mit deinem Hund', + 'data': {'page': 'diary'}, + 'tag': f'anniversary-{e["id"]}-{today.year}', + }) + sent_total += 1 + except Exception as ex: + logger.warning(f"Jahrestag-Reminder: Fehler für Eintrag {e['id']}: {ex}") + + logger.info(f"Jahrestags-Erinnerungen Job fertig — {len(entries)} Einträge geprüft, {sent_total} Push gesendet.") + _log_job("anniversary_reminders", "ok", f"{sent_total} Push von {len(entries)} Einträgen") + + +# ------------------------------------------------------------------ +# JOB: Monatlicher Rückblick (1. des Monats 10:00) +# ------------------------------------------------------------------ +async def _job_monthly_recap(): + """Sendet jedem User am 1. des Monats einen Rückblick des Vormonats.""" + today = datetime.now(tz=_TZ) + first_this = today.replace(day=1) + last_month_end = first_this - timedelta(days=1) + last_month_start = last_month_end.replace(day=1) + year_str = last_month_start.strftime('%Y') + month_str = last_month_start.strftime('%m') + month_label = last_month_start.strftime('%B %Y') + + logger.info(f"Monatlicher Rückblick Job läuft für {month_label}") + + with db() as conn: + # Alle User mit mindestens einem Hund + users = conn.execute( + "SELECT DISTINCT user_id FROM dogs" + ).fetchall() + + sent_total = 0 + for u in users: + user_id = u["user_id"] + try: + with db() as conn: + # Hunde des Users + dog_rows = conn.execute( + "SELECT id, name FROM dogs WHERE user_id=?", (user_id,) + ).fetchall() + if not dog_rows: + continue + + dog_ids = [d["id"] for d in dog_rows] + placeholders = ','.join('?' * len(dog_ids)) + + # km (Routen des Users im Vormonat) + km_row = conn.execute( + "SELECT ROUND(COALESCE(SUM(distanz_km),0),1) AS km FROM routes " + "WHERE user_id=? AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", + (user_id, year_str, month_str) + ).fetchone() + gesamt_km = km_row["km"] or 0.0 + + # Tagebucheinträge + eintraege = conn.execute( + f"SELECT COUNT(*) AS n FROM diary " + f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',datum)=? AND strftime('%m',datum)=?", + (*dog_ids, year_str, month_str) + ).fetchone()["n"] + + # Training-Sessions + training = conn.execute( + f"SELECT COUNT(*) AS n FROM training_sessions " + f"WHERE dog_id IN ({placeholders}) AND strftime('%Y',created_at)=? AND strftime('%m',created_at)=?", + (*dog_ids, year_str, month_str) + ).fetchone()["n"] + + # Lieblingsfoto (erstes Foto im Vormonat) + foto_row = conn.execute( + f"SELECT dm.url FROM diary_media dm " + f"JOIN diary d ON d.id=dm.diary_id " + f"WHERE d.dog_id IN ({placeholders}) AND dm.media_type='image' " + f"AND strftime('%Y',d.datum)=? AND strftime('%m',d.datum)=? " + f"ORDER BY d.datum ASC LIMIT 1", + (*dog_ids, year_str, month_str) + ).fetchone() + foto_url = foto_row["url"] if foto_row else None + + # Nur senden wenn mindestens eine Aktivität vorhanden + if eintraege == 0 and training == 0 and gesamt_km == 0: + continue + + dog_name = dog_rows[0]["name"] + parts = [] + if gesamt_km > 0: + parts.append(f"{gesamt_km} km gelaufen") + if eintraege > 0: + parts.append(f"{eintraege} Tagebucheintr{'ä' if True else 'a'}ge") + if training > 0: + parts.append(f"{training} Training-Sessions") + + body_text = " · ".join(parts) + + send_push_to_user(user_id, { + 'type': 'monthly_recap', + 'title': f'📅 {month_label}: Rückblick für {dog_name}', + 'body': body_text, + 'data': {'page': 'diary'}, + 'tag': f'monthly-recap-{year_str}-{month_str}', + }) + sent_total += 1 + except Exception as ex: + logger.error(f"Monatlicher Rückblick: Fehler für user {user_id}: {ex}") + + logger.info(f"Monatlicher Rückblick Job fertig — {len(users)} User geprüft, {sent_total} Push gesendet.") + _log_job("monthly_recap", "ok", f"{sent_total} Push für {month_label}") + + async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]: """Holt stündliche Wetterdaten für heute von Open-Meteo.""" import httpx diff --git a/backend/static/js/pages/dog-profile.js b/backend/static/js/pages/dog-profile.js index f80b034..6c0ede4 100644 --- a/backend/static/js/pages/dog-profile.js +++ b/backend/static/js/pages/dog-profile.js @@ -1953,6 +1953,163 @@ window.Page_dog_profile = (() => { } } + // ---------------------------------------------------------- + // JAHRESRÜCKBLICK — WRAPPED + // ---------------------------------------------------------- + async function _showWrappedModal(dog) { + const year = new Date().getFullYear(); + let data = null; + try { + data = await API.get(`/dogs/${dog.id}/wrapped?year=${year}`); + } catch (e) { + UI.toast.error('Rückblick konnte nicht geladen werden.'); + return; + } + + const name = _esc(data.dog_name); + const km = data.gesamt_km || 0; + const konfetti = km > 100; + + const _TYPEN = { + eintrag: 'Tagebuch', gassi: 'Gassi', training: 'Training', + tierarzt: 'Tierarzt', freizeit: 'Freizeit', milestone: 'Meilenstein', + }; + const aktivitaet = data.lieblings_aktivitaet + ? (_TYPEN[data.lieblings_aktivitaet] || data.lieblings_aktivitaet) + : null; + + const stadtpark = km > 0 ? Math.round(km / 1.5) : 0; + const schneeheld = data.wetter_kalt >= 10; + const pfotalalarm = data.wetter_warm >= 10; + + const _card = (content) => + `