From 20a4936397b83c4cb39074e9ec1ef6ba7bc33836 Mon Sep 17 00:00:00 2001 From: rene Date: Mon, 4 May 2026 20:54:12 +0200 Subject: [PATCH] =?UTF-8?q?Feature:=20Ban=20Yaro=20Wrapped=20+=20Jahrestag?= =?UTF-8?q?s-=20und=20Monatsr=C3=BCckblick=20(SW=20by-v699)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/dogs/{id}/wrapped?year= aggregiert km, Gassi-Tage, Fotos, Lieblingsmonat/-aktivität, Training, Gesundheit, Wetter-Stats aus SQLite - Frontend: Wrapped-Fullscreen-Modal in dog-profile.js — 5 Cards mit Swipe/Klick-Navigation, Dots, ESC-Taste, Copy-to-Clipboard auf Share-Card - Scheduler: _job_anniversary_reminders (täglich 09:00) sendet Push wenn heute ein Tagebucheintrag von vor 1+ Jahren existiert - Scheduler: _job_monthly_recap (1. des Monats 10:00) sendet Vormonat- Zusammenfassung (km, Einträge, Training) per Push an alle User - Beide Jobs im Status-Report-Log und Scheduler-Start-Log vermerkt - SW by-v699, APP_VER 699 --- backend/routes/dogs.py | 148 +++++++++++++++++++++- backend/scheduler.py | 163 ++++++++++++++++++++++++- backend/static/js/pages/dog-profile.js | 157 ++++++++++++++++++++++++ 3 files changed, 464 insertions(+), 4 deletions(-) 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) => + `
${content}
`; + + const cards = [ + _card(` +
🐾
+
+ Dein Jahr mit ${name} +
+
${year} in Zahlen
+ `), + _card(` +
👟
+
${km} km
+
zusammen gelaufen
+ ${stadtpark > 0 ? `
= ${stadtpark}× um den Stadtpark
` : ''} + ${konfetti ? `
🎉 Über 100 km!
` : ''} + `), + _card(` +
📔
+
${data.eintraege_gesamt}
+
Tagebucheinträge
+ ${data.fotos_gesamt > 0 ? `
📷 ${data.fotos_gesamt} Fotos
` : ''} + ${data.gassi_tage > 0 ? `
🐾 ${data.gassi_tage} aktive Tage
` : ''} + ${data.lieblings_monat ? `
Meiste Einträge: ${_esc(data.lieblings_monat)}
` : ''} + ${aktivitaet ? `
Lieblingsaktivität: ${_esc(aktivitaet)}
` : ''} + `), + _card(` +
🌡️
+
Wetter-Tapferkeit
+
+
❄️
+
${data.wetter_kalt}
+
kalte Tage
+
☀️
+
${data.wetter_warm}
+
heiße Tage
+
+ ${schneeheld ? `
❄️ Schneeheld!
` : ''} + ${pfotalalarm ? `
🔥 Pfoten-Alarm!
` : ''} + ${data.training_sessions > 0 ? `
🏋️ ${data.training_sessions} Training-Sessions
` : ''} + `), + _card(` +
🐾
+
Was für ein Jahr!
+
+ ${name} und du — ein unschlagbares Team.
${year} war unvergesslich. +
+ + `), + ]; + + let currentCard = 0; + const totalCards = cards.length; + + const renderDots = () => Array.from({ length: totalCards }, (_, i) => + `
` + ).join(''); + + const modalEl = document.createElement('div'); + modalEl.style.cssText = 'position:fixed;inset:0;z-index:9999;background:#0d0d1a;display:flex;flex-direction:column;overflow:hidden;'; + modalEl.innerHTML = ` +
+ +
+
+
${cards[0]}
+ + +
+
${renderDots()}
+ `; + + document.body.appendChild(modalEl); + + const cardContainer = modalEl.querySelector('#dp-wrapped-card-container'); + const dotsEl = modalEl.querySelector('#dp-wrapped-dots'); + const prevBtn = modalEl.querySelector('#dp-wrapped-prev'); + const nextBtn = modalEl.querySelector('#dp-wrapped-next'); + + const updateCard = () => { + cardContainer.innerHTML = cards[currentCard]; + dotsEl.innerHTML = renderDots(); + prevBtn.style.display = currentCard > 0 ? 'flex' : 'none'; + nextBtn.style.display = currentCard < totalCards - 1 ? 'flex' : 'none'; + if (currentCard === totalCards - 1) { + cardContainer.querySelector('#dp-wrapped-copy-btn')?.addEventListener('click', async () => { + const shareText = `🐾 ${name} & ich — Jahresrückblick ${year}\n` + + (km > 0 ? `👟 ${km} km gelaufen\n` : '') + + (data.eintraege_gesamt > 0 ? `📔 ${data.eintraege_gesamt} Tagebucheinträge\n` : '') + + (data.fotos_gesamt > 0 ? `📷 ${data.fotos_gesamt} Fotos\n` : '') + + (data.training_sessions > 0 ? `🏋️ ${data.training_sessions} Training-Sessions\n` : '') + + `\nbanyaro.app`; + try { + await navigator.clipboard.writeText(shareText); + UI.toast.success('Text kopiert!'); + } catch { + UI.toast.error('Kopieren fehlgeschlagen.'); + } + }); + } + }; + + prevBtn.addEventListener('click', () => { if (currentCard > 0) { currentCard--; updateCard(); } }); + nextBtn.addEventListener('click', () => { if (currentCard < totalCards - 1) { currentCard++; updateCard(); } }); + modalEl.querySelector('#dp-wrapped-close').addEventListener('click', () => modalEl.remove()); + + let touchStartX = 0; + modalEl.addEventListener('touchstart', e => { touchStartX = e.touches[0].clientX; }, { passive: true }); + modalEl.addEventListener('touchend', e => { + const dx = e.changedTouches[0].clientX - touchStartX; + if (Math.abs(dx) > 50) { + if (dx < 0 && currentCard < totalCards - 1) { currentCard++; updateCard(); } + if (dx > 0 && currentCard > 0) { currentCard--; updateCard(); } + } + }); + + const onKey = e => { if (e.key === 'Escape') { modalEl.remove(); document.removeEventListener('keydown', onKey); } }; + document.addEventListener('keydown', onKey); + } + + // ---------------------------------------------------------- // PUBLIC // ----------------------------------------------------------