Feature: Ban Yaro Wrapped + Jahrestags- und Monatsrückblick (SW by-v699)

- 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
This commit is contained in:
rene 2026-05-04 20:54:12 +02:00
parent 0fdc32eaf4
commit 20a4936397
3 changed files with 464 additions and 4 deletions

View file

@ -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