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:
parent
0fdc32eaf4
commit
20a4936397
3 changed files with 464 additions and 4 deletions
|
|
@ -181,18 +181,29 @@ async def get_welcome_dashboard(dog_id: int, user=Depends(get_current_user)):
|
||||||
raise HTTPException(404, "Hund nicht gefunden.")
|
raise HTTPException(404, "Hund nicht gefunden.")
|
||||||
|
|
||||||
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
|
# Zufälliges Foto aus den letzten 100 Tagebuchbildern
|
||||||
|
# Alle Querformat-Fotos (breiter als hoch) des Hundes, stabile Reihenfolge
|
||||||
photos = conn.execute(
|
photos = conn.execute(
|
||||||
"""SELECT dm.url FROM diary_media dm
|
"""SELECT dm.url FROM diary_media dm
|
||||||
JOIN diary d ON d.id = dm.diary_id
|
JOIN diary d ON d.id = dm.diary_id
|
||||||
WHERE d.dog_id=? AND dm.media_type='image'
|
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,)
|
(dog_id,)
|
||||||
).fetchall()
|
).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
|
random_photo = None
|
||||||
if photos:
|
if photos:
|
||||||
import datetime as _dt2
|
import datetime as _dt2
|
||||||
day_num = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
|
tick = (_dt2.date.today() - _dt2.date(2024, 1, 1)).days
|
||||||
chosen_url = photos[day_num % len(photos)]["url"]
|
chosen_url = photos[tick % len(photos)]["url"]
|
||||||
random_photo = {
|
random_photo = {
|
||||||
"url": chosen_url,
|
"url": chosen_url,
|
||||||
"preview_url": preview_url_from(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}")
|
@router.get("/{dog_id}")
|
||||||
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
async def get_dog(dog_id: int, user=Depends(get_current_user)):
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
|
|
|
||||||
|
|
@ -164,8 +164,24 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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()
|
_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():
|
def stop():
|
||||||
|
|
@ -890,6 +906,8 @@ async def _job_status_report():
|
||||||
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
|
"streak_reminder": "Streak-Erinnerung (täglich 19:00)",
|
||||||
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
|
"recall_check": "Tierfutter-Rückrufe (RASFF, täglich 08:00)",
|
||||||
"golden_gassi_hour": "Goldene Gassi-Stunde (täglich 07: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_html = ""
|
||||||
job_rows_txt = ""
|
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")
|
_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]:
|
async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]:
|
||||||
"""Holt stündliche Wetterdaten für heute von Open-Meteo."""
|
"""Holt stündliche Wetterdaten für heute von Open-Meteo."""
|
||||||
import httpx
|
import httpx
|
||||||
|
|
|
||||||
|
|
@ -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) =>
|
||||||
|
`<div style="min-height:320px;display:flex;flex-direction:column;
|
||||||
|
align-items:center;justify-content:center;text-align:center;
|
||||||
|
padding:32px 24px;gap:16px">${content}</div>`;
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
_card(`
|
||||||
|
<div style="font-size:3rem">🐾</div>
|
||||||
|
<div style="font-size:1.6rem;font-weight:800;color:#e8c96e;line-height:1.2">
|
||||||
|
Dein Jahr mit ${name}
|
||||||
|
</div>
|
||||||
|
<div style="font-size:1rem;color:#b8b0a0;font-weight:500">${year} in Zahlen</div>
|
||||||
|
`),
|
||||||
|
_card(`
|
||||||
|
<div style="font-size:2.5rem">👟</div>
|
||||||
|
<div style="font-size:3rem;font-weight:900;color:#e8c96e">${km} km</div>
|
||||||
|
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">zusammen gelaufen</div>
|
||||||
|
${stadtpark > 0 ? `<div style="font-size:0.85rem;color:#888;margin-top:4px">= ${stadtpark}× um den Stadtpark</div>` : ''}
|
||||||
|
${konfetti ? `<div style="font-size:1.5rem;margin-top:8px">🎉 Über 100 km!</div>` : ''}
|
||||||
|
`),
|
||||||
|
_card(`
|
||||||
|
<div style="font-size:2.5rem">📔</div>
|
||||||
|
<div style="font-size:3rem;font-weight:900;color:#e8c96e">${data.eintraege_gesamt}</div>
|
||||||
|
<div style="font-size:1rem;color:#d0c8b8;font-weight:600">Tagebucheinträge</div>
|
||||||
|
${data.fotos_gesamt > 0 ? `<div style="font-size:1.1rem;color:#a0c890;font-weight:700;margin-top:4px">📷 ${data.fotos_gesamt} Fotos</div>` : ''}
|
||||||
|
${data.gassi_tage > 0 ? `<div style="font-size:0.9rem;color:#888;margin-top:4px">🐾 ${data.gassi_tage} aktive Tage</div>` : ''}
|
||||||
|
${data.lieblings_monat ? `<div style="font-size:0.85rem;color:#b89a6a;margin-top:4px">Meiste Einträge: ${_esc(data.lieblings_monat)}</div>` : ''}
|
||||||
|
${aktivitaet ? `<div style="font-size:0.85rem;color:#888">Lieblingsaktivität: ${_esc(aktivitaet)}</div>` : ''}
|
||||||
|
`),
|
||||||
|
_card(`
|
||||||
|
<div style="font-size:2rem">🌡️</div>
|
||||||
|
<div style="font-size:1.2rem;font-weight:700;color:#d0c8b8;margin-bottom:8px">Wetter-Tapferkeit</div>
|
||||||
|
<div style="display:flex;gap:32px;justify-content:center;flex-wrap:wrap">
|
||||||
|
<div><div style="font-size:2rem">❄️</div>
|
||||||
|
<div style="font-size:2rem;font-weight:900;color:#8ecfef">${data.wetter_kalt}</div>
|
||||||
|
<div style="font-size:0.8rem;color:#888">kalte Tage</div></div>
|
||||||
|
<div><div style="font-size:2rem">☀️</div>
|
||||||
|
<div style="font-size:2rem;font-weight:900;color:#f0b040">${data.wetter_warm}</div>
|
||||||
|
<div style="font-size:0.8rem;color:#888">heiße Tage</div></div>
|
||||||
|
</div>
|
||||||
|
${schneeheld ? `<div style="margin-top:12px;background:#1a3a5c;border-radius:8px;padding:6px 16px;font-size:0.9rem;font-weight:700;color:#8ecfef">❄️ Schneeheld!</div>` : ''}
|
||||||
|
${pfotalalarm ? `<div style="margin-top:12px;background:#3a2000;border-radius:8px;padding:6px 16px;font-size:0.9rem;font-weight:700;color:#f0b040">🔥 Pfoten-Alarm!</div>` : ''}
|
||||||
|
${data.training_sessions > 0 ? `<div style="margin-top:12px;font-size:0.85rem;color:#a0c890">🏋️ ${data.training_sessions} Training-Sessions</div>` : ''}
|
||||||
|
`),
|
||||||
|
_card(`
|
||||||
|
<div style="font-size:2.5rem">🐾</div>
|
||||||
|
<div style="font-size:1.3rem;font-weight:800;color:#e8c96e">Was für ein Jahr!</div>
|
||||||
|
<div style="font-size:0.95rem;color:#b8b0a0;line-height:1.5;max-width:280px">
|
||||||
|
${name} und du — ein unschlagbares Team.<br>${year} war unvergesslich.
|
||||||
|
</div>
|
||||||
|
<button id="dp-wrapped-copy-btn" style="
|
||||||
|
margin-top:12px;background:#e8c96e;color:#1a1a2e;font-weight:800;
|
||||||
|
border:none;border-radius:8px;padding:10px 20px;cursor:pointer;font-size:1rem">
|
||||||
|
📋 Text kopieren
|
||||||
|
</button>
|
||||||
|
`),
|
||||||
|
];
|
||||||
|
|
||||||
|
let currentCard = 0;
|
||||||
|
const totalCards = cards.length;
|
||||||
|
|
||||||
|
const renderDots = () => Array.from({ length: totalCards }, (_, i) =>
|
||||||
|
`<div style="width:8px;height:8px;border-radius:50%;background:${i === currentCard ? '#e8c96e' : '#444'};transition:background .3s"></div>`
|
||||||
|
).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 = `
|
||||||
|
<div style="display:flex;justify-content:flex-end;padding:16px 20px 0">
|
||||||
|
<button id="dp-wrapped-close" style="background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:36px;height:36px;font-size:1.2rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center">×</button>
|
||||||
|
</div>
|
||||||
|
<div style="flex:1;display:flex;align-items:center;justify-content:center;overflow:hidden;position:relative">
|
||||||
|
<div id="dp-wrapped-card-container" style="width:100%;max-width:400px;color:#fff;">${cards[0]}</div>
|
||||||
|
<button id="dp-wrapped-prev" style="position:absolute;left:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:none;align-items:center;justify-content:center">‹</button>
|
||||||
|
<button id="dp-wrapped-next" style="position:absolute;right:8px;top:50%;transform:translateY(-50%);background:rgba(255,255,255,0.1);border:none;border-radius:50%;width:40px;height:40px;font-size:1.3rem;color:#fff;cursor:pointer;display:flex;align-items:center;justify-content:center">›</button>
|
||||||
|
</div>
|
||||||
|
<div id="dp-wrapped-dots" style="display:flex;gap:8px;justify-content:center;padding:16px 0 32px">${renderDots()}</div>
|
||||||
|
`;
|
||||||
|
|
||||||
|
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
|
// PUBLIC
|
||||||
// ----------------------------------------------------------
|
// ----------------------------------------------------------
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue