diff --git a/backend/auth.py b/backend/auth.py index 55c63fc..a4d5af3 100644 --- a/backend/auth.py +++ b/backend/auth.py @@ -87,7 +87,7 @@ def get_current_user( user_id = int(payload["sub"]) with db() as conn: row = conn.execute( - "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?", + "SELECT id, email, name, rolle, is_premium, is_moderator, is_banned, ban_reason, is_social_media, notes_ki_enabled, gassi_stunde_push, breeder_status, is_founder, is_partner, founder_number, email_verified, luna_trial_until FROM users WHERE id=?", (user_id,) ).fetchone() diff --git a/backend/routes/profile.py b/backend/routes/profile.py index cf1f01b..9762f95 100644 --- a/backend/routes/profile.py +++ b/backend/routes/profile.py @@ -26,6 +26,7 @@ class ProfileUpdate(BaseModel): social_link: Optional[str] = None profil_sichtbarkeit: Optional[str] = None notes_ki_enabled: Optional[int] = None + gassi_stunde_push: Optional[int] = None def _load_user(user_id: int) -> dict: diff --git a/backend/scheduler.py b/backend/scheduler.py index 4aeb89a..9c2f07c 100644 --- a/backend/scheduler.py +++ b/backend/scheduler.py @@ -156,8 +156,16 @@ def start(): replace_existing=True, misfire_grace_time=3600, ) + # Täglich 07:00 Uhr — Goldene Gassi-Stunde + _scheduler.add_job( + _job_golden_gassi_hour, + CronTrigger(hour=7, minute=0), + id="golden_gassi_hour", + 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. 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. OSM-Cache: on-demand (kein Prewarm).") def stop(): @@ -881,6 +889,7 @@ async def _job_status_report(): "quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)", "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)", } job_rows_html = "" job_rows_txt = "" @@ -1288,3 +1297,186 @@ async def _job_recurring_expenses(): except Exception as e: logger.error(f"Daueraufträge-Job Fehler: {e}") _log_job("recurring_expenses", "error", str(e)) + + +# ------------------------------------------------------------------ +# JOB: Goldene Gassi-Stunde (täglich 07:00 Uhr) +# ------------------------------------------------------------------ +async def _job_golden_gassi_hour(): + """ + Berechnet für jeden User mit aktivierter Einstellung (gassi_stunde_push=1) + das beste 2h-Wetterfenster des Tages und schickt eine Push-Notification. + + Score-Logik pro Stunde (max. 10 Punkte): + - Temperatur 10–20°C → +3 + - Temperatur 5–10°C → +1 + - Niederschlagswahrsch. <20% → +3, <40% → +1 + - Windgeschwindigkeit <20 km/h → +2, <30 km/h → +1 + - Stunden 07–19 Uhr (Tageslicht) → +2 + Bestes fortlaufendes 2h-Fenster (Summe zweier aufeinanderfolgender Stunden). + """ + import httpx + from datetime import date as _date + + logger.info("Goldene-Gassi-Stunde Job läuft") + + # Alle User mit aktivierter Einstellung + mindestens einer Push-Subscription + with db() as conn: + users = conn.execute(""" + SELECT DISTINCT u.id AS user_id, + ps.last_lat, ps.last_lon + FROM users u + JOIN push_subscriptions ps ON ps.user_id = u.id + WHERE u.gassi_stunde_push = 1 + """).fetchall() + + users = [dict(u) for u in users] + logger.info(f"Goldene-Gassi-Stunde: {len(users)} User mit aktivierter Einstellung.") + + if not users: + _log_job("golden_gassi_hour", "ok", "0 User mit Einstellung aktiv") + return + + sent_total = 0 + + for u in users: + lat = u["last_lat"] or 48.1351 # Fallback: München + lon = u["last_lon"] or 11.5820 + + try: + hourly = await _fetch_hourly_weather(lat, lon) + except Exception as e: + logger.warning(f"Goldene-Gassi-Stunde: Wetter-Fehler für user {u['user_id']}: {e}") + continue + + if not hourly: + continue + + best_start, best_score, best_temp, best_wind = _find_best_gassi_window(hourly) + + if best_score < 3: + # Heute kein gutes Wetterfenster → kein Push + logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} — kein gutes Fenster (score={best_score})") + continue + + hour_end = (best_start + 2) % 24 + temp_str = f"{best_temp:.0f}°C" if best_temp is not None else "–" + wind_str = "Kaum Wind" if (best_wind is not None and best_wind < 20) else ( + f"{best_wind:.0f} km/h Wind" if best_wind is not None else "") + + body_parts = [f"Bestes Wetter zwischen {best_start:02d}:00–{hour_end:02d}:00 Uhr", + f"· {temp_str}"] + if wind_str: + body_parts.append(f"· {wind_str}") + + sent = send_push_to_user(u["user_id"], { + "type": "golden_gassi_hour", + "title": "☀️ Goldene Gassi-Stunde heute!", + "body": " ".join(body_parts), + "data": {"page": "wetter"}, + "tag": f"gassi-{_date.today()}", + }) + sent_total += sent + logger.info(f"Goldene-Gassi-Stunde: user {u['user_id']} → {best_start:02d}:00 (score={best_score}, {temp_str}) — Push: {sent}") + + logger.info(f"Goldene-Gassi-Stunde Job fertig — {len(users)} User, {sent_total} Push gesendet.") + _log_job("golden_gassi_hour", "ok", f"{sent_total} Push an {len(users)} User") + + +async def _fetch_hourly_weather(lat: float, lon: float) -> list[dict]: + """Holt stündliche Wetterdaten für heute von Open-Meteo.""" + import httpx + from datetime import date as _date + + today = _date.today().isoformat() + url = ( + "https://api.open-meteo.com/v1/forecast" + f"?latitude={lat}&longitude={lon}" + "&hourly=temperature_2m,precipitation_probability,windspeed_10m" + "&timezone=Europe%2FBerlin&forecast_days=1" + ) + async with httpx.AsyncClient(timeout=8.0) as client: + resp = await client.get(url) + resp.raise_for_status() + raw = resp.json() + + hourly = raw.get("hourly", {}) + times = hourly.get("time", []) + temps = hourly.get("temperature_2m", []) + precips = hourly.get("precipitation_probability", []) + winds = hourly.get("windspeed_10m", []) + + result = [] + for i, ts in enumerate(times): + if not ts.startswith(today): + continue + hour = int(ts[11:13]) + result.append({ + "hour": hour, + "temp": temps[i] if i < len(temps) else None, + "precip": precips[i] if i < len(precips) else None, + "wind": winds[i] if i < len(winds) else None, + }) + return result + + +def _score_hour(h: dict) -> int: + """Berechnet Gassi-Score für eine einzelne Stunde (0–10 Punkte).""" + score = 0 + temp = h.get("temp") + precip = h.get("precip") + wind = h.get("wind") + hour = h.get("hour", 12) + + # Temperatur + if temp is not None: + if 10 <= temp <= 20: + score += 3 + elif 5 <= temp < 10 or 20 < temp <= 25: + score += 1 + + # Niederschlagswahrscheinlichkeit + if precip is not None: + if precip < 20: + score += 3 + elif precip < 40: + score += 1 + + # Wind + if wind is not None: + if wind < 20: + score += 2 + elif wind < 30: + score += 1 + + # Tageslicht (07–19 Uhr) + if 7 <= hour <= 19: + score += 2 + + return score + + +def _find_best_gassi_window(hourly: list[dict]) -> tuple[int, int, float | None, float | None]: + """ + Findet das beste aufeinanderfolgende 2h-Fenster. + Gibt (start_hour, total_score, avg_temp, avg_wind) zurück. + """ + best_start = 8 + best_score = -1 + best_temp = None + best_wind = None + + for i in range(len(hourly) - 1): + h1 = hourly[i] + h2 = hourly[i + 1] + combined = _score_hour(h1) + _score_hour(h2) + if combined > best_score: + best_score = combined + best_start = h1["hour"] + # Durchschnittswerte für Anzeige + temps = [x for x in [h1.get("temp"), h2.get("temp")] if x is not None] + winds = [x for x in [h1.get("wind"), h2.get("wind")] if x is not None] + best_temp = sum(temps) / len(temps) if temps else None + best_wind = sum(winds) / len(winds) if winds else None + + return best_start, best_score, best_temp, best_wind diff --git a/backend/static/js/pages/settings.js b/backend/static/js/pages/settings.js index e90ebe8..0b8cd88 100644 --- a/backend/static/js/pages/settings.js +++ b/backend/static/js/pages/settings.js @@ -313,7 +313,7 @@ window.Page_settings = (() => { -