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"])
|
user_id = int(payload["sub"])
|
||||||
with db() as conn:
|
with db() as conn:
|
||||||
row = conn.execute(
|
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,)
|
(user_id,)
|
||||||
).fetchone()
|
).fetchone()
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,7 @@ class ProfileUpdate(BaseModel):
|
||||||
social_link: Optional[str] = None
|
social_link: Optional[str] = None
|
||||||
profil_sichtbarkeit: Optional[str] = None
|
profil_sichtbarkeit: Optional[str] = None
|
||||||
notes_ki_enabled: Optional[int] = None
|
notes_ki_enabled: Optional[int] = None
|
||||||
|
gassi_stunde_push: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
def _load_user(user_id: int) -> dict:
|
def _load_user(user_id: int) -> dict:
|
||||||
|
|
|
||||||
|
|
@ -156,8 +156,16 @@ def start():
|
||||||
replace_existing=True,
|
replace_existing=True,
|
||||||
misfire_grace_time=3600,
|
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()
|
_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():
|
def stop():
|
||||||
|
|
@ -881,6 +889,7 @@ async def _job_status_report():
|
||||||
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
|
"quarterly_report": "Quartalsbericht (1. Feb/Mai/Aug/Nov)",
|
||||||
"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)",
|
||||||
}
|
}
|
||||||
job_rows_html = ""
|
job_rows_html = ""
|
||||||
job_rows_txt = ""
|
job_rows_txt = ""
|
||||||
|
|
@ -1288,3 +1297,186 @@ async def _job_recurring_expenses():
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"Daueraufträge-Job Fehler: {e}")
|
logger.error(f"Daueraufträge-Job Fehler: {e}")
|
||||||
_log_job("recurring_expenses", "error", str(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>
|
</div>
|
||||||
|
|
||||||
<!-- KI-Notiz-Assistent -->
|
<!-- 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>
|
<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="flex:1">
|
||||||
<div style="font-weight:500">KI-Notiz-Assistent</div>
|
<div style="font-weight:500">KI-Notiz-Assistent</div>
|
||||||
|
|
@ -336,6 +336,30 @@ window.Page_settings = (() => {
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</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>
|
||||||
</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();
|
_loadReferral();
|
||||||
_loadBreederCard();
|
_loadBreederCard();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue