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:
parent
af1508c0de
commit
6bf088df56
4 changed files with 239 additions and 3 deletions
|
|
@ -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()
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue