Feature: Goldene Gassi-Stunde — täglicher Push mit bestem Wetterfenster (SW by-v693)

- Scheduler-Job täglich 07:00: berechnet bestes 2h-Fenster via Open-Meteo
- Score-System (max. 10 Pkt): Temperatur, Niederschlag, Wind, Tageszeit
- User-Fallback auf letzten bekannten Standort (push_subscriptions.last_lat/lon) oder München
- Nur Push wenn score >= 3 (kein sinnloser Push bei schlechtem Wetter)
- DB-Migration: users.gassi_stunde_push (Boolean, default 0)
- settings.js: Toggle "Goldene Gassi-Stunde täglich" in App-Einstellungen
- PATCH /api/profile + auth.py /me: gassi_stunde_push Feld
This commit is contained in:
rene 2026-05-04 20:22:02 +02:00
parent af1508c0de
commit 6bf088df56
4 changed files with 239 additions and 3 deletions

View file

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

View file

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

View file

@ -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 1020°C +3
- Temperatur 510°C +1
- Niederschlagswahrsch. <20% +3, <40% +1
- Windgeschwindigkeit <20 km/h +2, <30 km/h +1
- Stunden 0719 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 (010 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 (0719 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

View file

@ -313,7 +313,7 @@ window.Page_settings = (() => {
</div>
<!-- KI-Notiz-Assistent -->
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4);border-bottom:1px solid var(--c-border)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#brain"></use></svg>
<div style="flex:1">
<div style="font-weight:500">KI-Notiz-Assistent</div>
@ -336,6 +336,30 @@ window.Page_settings = (() => {
</label>
</div>
<!-- Goldene Gassi-Stunde -->
<div style="display:flex;align-items:center;gap:var(--space-3);padding:var(--space-4)">
<svg class="ph-icon" aria-hidden="true" style="width:1.25rem;height:1.25rem"><use href="/icons/phosphor.svg#paw-print"></use></svg>
<div style="flex:1">
<div style="font-weight:500">Goldene Gassi-Stunde täglich</div>
<div style="font-size:var(--text-xs);color:var(--c-text-secondary);margin-top:2px">
Täglich um 07:00 Uhr: bestes Wetterfenster für den Gassi-Gang
</div>
</div>
<label style="position:relative;display:inline-block;width:44px;height:24px;flex-shrink:0">
<input type="checkbox" id="toggle-gassi-stunde"
style="opacity:0;width:0;height:0;position:absolute"
${u.gassi_stunde_push ? 'checked' : ''}>
<span style="position:absolute;cursor:pointer;inset:0;border-radius:12px;
background:${u.gassi_stunde_push ? 'var(--c-primary)' : 'var(--c-border)'};transition:.2s"
id="toggle-gassi-stunde-track"></span>
<span id="toggle-gassi-stunde-thumb"
style="position:absolute;top:2px;left:${u.gassi_stunde_push ? '22px' : '2px'};
width:20px;height:20px;border-radius:50%;
background:#fff;transition:.2s;
box-shadow:0 1px 3px rgba(0,0,0,.3)"></span>
</label>
</div>
</div>
</div>
@ -785,6 +809,25 @@ window.Page_settings = (() => {
}
});
document.getElementById('toggle-gassi-stunde')?.addEventListener('change', async e => {
const enabled = e.target.checked;
const track = document.getElementById('toggle-gassi-stunde-track');
const thumb = document.getElementById('toggle-gassi-stunde-thumb');
if (track) track.style.background = enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = enabled ? '22px' : '2px';
try {
await API.patch('/profile', { gassi_stunde_push: enabled ? 1 : 0 });
_appState.user.gassi_stunde_push = enabled ? 1 : 0;
UI.toast.success(enabled ? 'Goldene Gassi-Stunde aktiviert.' : 'Goldene Gassi-Stunde deaktiviert.');
} catch (err) {
UI.toast.error(err?.message || 'Einstellung konnte nicht gespeichert werden.');
// Revert UI
e.target.checked = !enabled;
if (track) track.style.background = !enabled ? 'var(--c-primary)' : 'var(--c-border)';
if (thumb) thumb.style.left = !enabled ? '22px' : '2px';
}
});
_loadReferral();
_loadBreederCard();
}