Pull daily instead of on day 25; skip past dates and weekend misreads
All checks were successful
Deploy / deploy (push) Successful in 46s
All checks were successful
Deploy / deploy (push) Successful in 46s
The monthly day-25 pull pushed whatever month the district's permalink served. September's PDF was still live on 9/29, so the 9/25 pull re-pushed September and October would not have been pulled until 10/25. Home Assistant now dedups lunch events per date, so pull every day and pick up a new month the evening it's posted. - push only today onward; skip weekend dates that have items (a misread date: Fri 9/25's menu once landed on Sat 9/26) and log a warning - settings schema is just calendar_run_time; load_settings keeps only known keys and startup rewrites the file, dropping stale SMS-era data - UI: remove day-of-month field and the "no dedup" warning, fix buttons that reverted to "This Week" labels - docs: daily scheduler, HA queued mode + dedup, calendar is iCloud Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
a809aa5a5a
commit
3d374c4417
3 changed files with 105 additions and 116 deletions
107
app.py
107
app.py
|
|
@ -2,13 +2,12 @@
|
|||
Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home
|
||||
Assistant calendar. Visit http://localhost:2323 to manage settings.
|
||||
"""
|
||||
import calendar
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import threading
|
||||
import time
|
||||
from datetime import datetime
|
||||
from datetime import date, datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
import requests
|
||||
|
|
@ -25,10 +24,10 @@ app = Flask(__name__)
|
|||
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/data/settings.json"))
|
||||
PORT = int(os.environ.get("PORT", "2323"))
|
||||
|
||||
# Env-var defaults (used only when no settings file exists yet)
|
||||
# Env-var defaults (used only when no settings file exists yet). These are
|
||||
# also the complete settings schema: load_settings() drops any other key.
|
||||
_DEFAULTS = {
|
||||
"calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"),
|
||||
"calendar_run_day_of_month": int(os.environ.get("CALENDAR_RUN_DAY_OF_MONTH", "25")),
|
||||
}
|
||||
|
||||
_status = {"last_calendar_run": None, "calendar_result": None}
|
||||
|
|
@ -42,11 +41,12 @@ def load_settings() -> dict:
|
|||
try:
|
||||
with open(SETTINGS_FILE) as f:
|
||||
# Merge over _DEFAULTS so a settings.json from before a schema
|
||||
# change (e.g. the pre-calendar SMS-era file, or the
|
||||
# weekly-schedule era before calendar_run_day_of_month
|
||||
# existed) still gets a valid schedule rather than silently
|
||||
# scheduling nothing.
|
||||
return {**_DEFAULTS, **json.load(f)}
|
||||
# change still gets a valid schedule rather than silently
|
||||
# scheduling nothing, and keep only current keys so stale
|
||||
# ones (SMS-era phone numbers, the old day-of-month pull)
|
||||
# don't linger. Startup rewrites the file with this result.
|
||||
stored = json.load(f)
|
||||
return {**_DEFAULTS, **{k: v for k, v in stored.items() if k in _DEFAULTS}}
|
||||
except Exception:
|
||||
pass
|
||||
return dict(_DEFAULTS)
|
||||
|
|
@ -83,11 +83,15 @@ def _push_calendar(items: list[str], date_str: str | None) -> str:
|
|||
|
||||
|
||||
def push_month_to_calendar() -> None:
|
||||
"""Fetch whichever month is currently published and push every day that
|
||||
has a posted menu — days with nothing posted (weekends, breaks,
|
||||
holidays) are skipped automatically since there's nothing to create an
|
||||
event from. Runs on its own schedule (calendar_run_time/
|
||||
calendar_run_day_of_month), independent of anything else."""
|
||||
"""Fetch whichever month is currently published and push every day from
|
||||
today onward that has a posted menu. Days with nothing posted (breaks,
|
||||
holidays) are skipped since there's nothing to create an event from;
|
||||
past days are skipped since they're already on the calendar or no
|
||||
longer matter; weekend days are skipped even if the PDF read put a menu
|
||||
on one (there's never school lunch on a weekend, so that's a misread
|
||||
date — seen once as Fri 9/25's menu landing on Sat 9/26). Runs daily
|
||||
(calendar_run_time); Home Assistant skips dates that already have a
|
||||
lunch event, so repeated pulls don't create duplicates."""
|
||||
global _status
|
||||
|
||||
try:
|
||||
|
|
@ -97,8 +101,20 @@ def push_month_to_calendar() -> None:
|
|||
_status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"}
|
||||
return
|
||||
|
||||
pushed = errors = no_items = 0
|
||||
today = date.today()
|
||||
pushed = errors = no_items = past = weekend = 0
|
||||
for day in month:
|
||||
d = date.fromisoformat(day["date"])
|
||||
if d < today:
|
||||
past += 1
|
||||
continue
|
||||
if d.weekday() >= 5:
|
||||
if day["items"]:
|
||||
log.warning(f"Skipping {day['date']} ({d:%a}): menu on a weekend is a misread date")
|
||||
weekend += 1
|
||||
else:
|
||||
no_items += 1
|
||||
continue
|
||||
result = _push_calendar(day["items"], day["date"])
|
||||
if result == "ok":
|
||||
pushed += 1
|
||||
|
|
@ -107,7 +123,8 @@ def push_month_to_calendar() -> None:
|
|||
else:
|
||||
errors += 1
|
||||
|
||||
log.info(f"Month push: {pushed} pushed, {errors} errors, {no_items} no menu")
|
||||
log.info(f"Month push: {pushed} pushed, {errors} errors, {no_items} no menu, "
|
||||
f"{past} past, {weekend} weekend misreads skipped")
|
||||
|
||||
if errors:
|
||||
summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "")
|
||||
|
|
@ -115,6 +132,8 @@ def push_month_to_calendar() -> None:
|
|||
summary = f"ok ({pushed} day{'s' if pushed != 1 else ''})"
|
||||
else:
|
||||
summary = "no items"
|
||||
if weekend:
|
||||
summary += f", {weekend} weekend misread{'s' if weekend != 1 else ''} skipped"
|
||||
|
||||
_status = {
|
||||
**_status,
|
||||
|
|
@ -125,43 +144,20 @@ def push_month_to_calendar() -> None:
|
|||
|
||||
# ── Scheduler ─────────────────────────────────────────────────────────────────
|
||||
|
||||
def _maybe_push_month(day_of_month: int) -> None:
|
||||
"""schedule fires this daily at run_time; only actually pushes on the
|
||||
configured day of the month (the `schedule` library has no native
|
||||
day-of-month trigger)."""
|
||||
if datetime.now().day == day_of_month:
|
||||
push_month_to_calendar()
|
||||
|
||||
|
||||
def apply_calendar_schedule(settings: dict) -> None:
|
||||
with _lock:
|
||||
schedule.clear("calendar")
|
||||
run_time = settings.get("calendar_run_time", "18:00")
|
||||
day_of_month = settings.get("calendar_run_day_of_month", 25)
|
||||
schedule.every().day.at(run_time).do(_maybe_push_month, day_of_month).tag("calendar")
|
||||
log.info(f"Scheduled calendar pull: day {day_of_month} of each month at {run_time}")
|
||||
schedule.every().day.at(run_time).do(push_month_to_calendar).tag("calendar")
|
||||
log.info(f"Scheduled calendar pull: daily at {run_time}")
|
||||
|
||||
|
||||
def next_calendar_run(settings: dict) -> datetime:
|
||||
"""The `schedule` job above fires daily (it has no native day-of-month
|
||||
trigger — see _maybe_push_month), so its own next_run is just
|
||||
"tomorrow", not the actual next push. Compute the real one directly
|
||||
from settings for display in the UI."""
|
||||
run_time = settings.get("calendar_run_time", "18:00")
|
||||
hour, minute = (int(p) for p in run_time.split(":"))
|
||||
day_of_month = settings.get("calendar_run_day_of_month", 25)
|
||||
|
||||
now = datetime.now()
|
||||
candidate = now.replace(
|
||||
day=min(day_of_month, calendar.monthrange(now.year, now.month)[1]),
|
||||
hour=hour, minute=minute, second=0, microsecond=0,
|
||||
)
|
||||
if candidate <= now:
|
||||
y, m = (now.year, now.month + 1) if now.month < 12 else (now.year + 1, 1)
|
||||
candidate = candidate.replace(
|
||||
year=y, month=m, day=min(day_of_month, calendar.monthrange(y, m)[1]),
|
||||
)
|
||||
return candidate
|
||||
candidate = now.replace(hour=hour, minute=minute, second=0, microsecond=0)
|
||||
return candidate if candidate > now else candidate + timedelta(days=1)
|
||||
|
||||
|
||||
def _schedule_loop() -> None:
|
||||
|
|
@ -195,7 +191,7 @@ def api_get_settings():
|
|||
def api_post_settings():
|
||||
data = request.get_json(force=True)
|
||||
s = load_settings()
|
||||
for key in ("calendar_run_time", "calendar_run_day_of_month"):
|
||||
for key in _DEFAULTS:
|
||||
if key in data:
|
||||
s[key] = data[key]
|
||||
save_settings(s)
|
||||
|
|
@ -369,11 +365,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
|
|||
<label class="field-label">Pull Time</label>
|
||||
<input type="time" id="f-time" style="max-width:160px">
|
||||
</div>
|
||||
<div class="field">
|
||||
<label class="field-label">Pull Day of Month</label>
|
||||
<input type="number" id="f-day" min="1" max="28" style="max-width:100px">
|
||||
<div class="item-sub" style="margin-top:8px;">Fires once a month and pushes whichever month is currently published — e.g. day 25 catches next month's menu once the district posts it near month-end. There's no dedup, so re-pushing the same month creates duplicate calendar events.</div>
|
||||
</div>
|
||||
<div class="item-sub" style="margin-bottom:14px;">Pulls every day and pushes today onward from whichever month the district has published, so next month's menu lands on the calendar as soon as it's posted. Home Assistant skips dates that already have a lunch event, so daily pulls don't create duplicates.</div>
|
||||
<hr class="divider">
|
||||
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
|
||||
</div>
|
||||
|
|
@ -413,7 +405,6 @@ function applyIntegrations(ig) {
|
|||
|
||||
function applySettings(s) {
|
||||
document.getElementById('f-time').value = s.calendar_run_time || '18:00';
|
||||
document.getElementById('f-day').value = s.calendar_run_day_of_month || 25;
|
||||
}
|
||||
|
||||
function applyStatus(st) {
|
||||
|
|
@ -428,8 +419,7 @@ function applyStatus(st) {
|
|||
|
||||
async function saveSchedule() {
|
||||
const calendar_run_time = document.getElementById('f-time').value;
|
||||
const calendar_run_day_of_month = parseInt(document.getElementById('f-day').value, 10);
|
||||
await post('/api/settings', {calendar_run_time, calendar_run_day_of_month});
|
||||
await post('/api/settings', {calendar_run_time});
|
||||
toast('Schedule saved');
|
||||
load();
|
||||
}
|
||||
|
|
@ -440,7 +430,7 @@ async function pushCalendar() {
|
|||
btn.textContent = 'Pushing…';
|
||||
await post('/api/push-calendar', {});
|
||||
toast('Triggered! Check status in a moment.');
|
||||
setTimeout(() => { btn.disabled=false; btn.textContent='Push This Week to Calendar'; load(); }, 6000);
|
||||
setTimeout(() => { btn.disabled=false; btn.textContent='Push This Month to Calendar'; load(); }, 6000);
|
||||
}
|
||||
|
||||
async function previewMenu() {
|
||||
|
|
@ -465,7 +455,7 @@ async function previewMenu() {
|
|||
box.style.display = '';
|
||||
} finally {
|
||||
btn.disabled = false;
|
||||
btn.textContent = "Preview This Week's Menu";
|
||||
btn.textContent = "Preview This Month's Menu";
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -492,11 +482,12 @@ setInterval(load, 30000);
|
|||
# ── Entrypoint ────────────────────────────────────────────────────────────────
|
||||
|
||||
if __name__ == "__main__":
|
||||
if not SETTINGS_FILE.exists():
|
||||
log.info(f"Creating default settings at {SETTINGS_FILE}")
|
||||
save_settings(_DEFAULTS)
|
||||
# Always rewrite: creates the file on first run, and prunes keys the
|
||||
# current schema no longer uses (see load_settings).
|
||||
settings = load_settings()
|
||||
save_settings(settings)
|
||||
|
||||
apply_calendar_schedule(load_settings())
|
||||
apply_calendar_schedule(settings)
|
||||
threading.Thread(target=_schedule_loop, daemon=True).start()
|
||||
log.info(f"Web UI → http://localhost:{PORT}")
|
||||
app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue