Pull daily instead of on day 25; skip past dates and weekend misreads
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:
Barely Removable 2026-09-29 10:49:59 -07:00
parent a809aa5a5a
commit 3d374c4417
3 changed files with 105 additions and 116 deletions

104
CLAUDE.md
View file

@ -11,12 +11,17 @@ user's request in favor of Home Assistant calendar events exclusively. If
you find references to texting/SMS/phone numbers anywhere (docs, old
commits), they describe the pre-2026-09-03 app, not the current one.
**2026-09-29: monthly → daily pulls, HA-side dedup.** See "Pull scheduler"
and "Home Assistant integration" below. Anything describing a day-25
monthly pull or "no dedup, re-pushing creates duplicates" describes the
pre-2026-09-29 app.
**2026-09-08: pivoted from Nutrislice to a Claude-read PDF, and from
weekly to monthly pulls.** The district discontinued Nutrislice (the old
scraper hit `kyrene.api.nutrislice.com`, a clean per-day JSON API) in
favor of publishing one monthly menu PDF per grade band on
`kyrene.org/family-student-resources/food-services/school-menus`. See
"Menu source" and "Monthly pull scheduler" below for how that's handled
"Menu source" and "Daily pull scheduler" below for how that's handled
now. Anything describing a weekly Nutrislice pull describes the
pre-2026-09-08 app.
@ -54,16 +59,15 @@ pre-2026-09-08 app.
fail-soft-into-`_status`-error behavior as a Home Assistant push failure.
`GET /api/integrations` reports both keys' configured/not-configured
state.
- Settings (calendar pull schedule) persist to `/data/settings.json`, not
environment variables — env vars (`CALENDAR_RUN_TIME`,
`CALENDAR_RUN_DAY_OF_MONTH`) are defaults used only the very first time,
before a settings file exists. `load_settings()` merges the file's
contents *over* `_DEFAULTS` rather than returning the file verbatim —
this matters because the server's settings.json predates several schema
changes (SMS era, then the weekly-schedule era) and is missing whatever
keys the current schema expects; without the merge, the scheduler would
silently come up with zero jobs after every deploy until someone happened
to re-save the Schedule card in the UI.
- Settings (just `calendar_run_time`) persist to `/data/settings.json`, not
environment variables — the env var `CALENDAR_RUN_TIME` is a default used
only before a settings file exists. `_DEFAULTS` is the complete schema:
`load_settings()` merges the file *over* `_DEFAULTS` (so an old file
missing a key still yields a valid schedule instead of zero jobs) and
**keeps only keys in `_DEFAULTS`**; startup rewrites the file with that
result. That pruning (2026-09-29) removed the SMS era's phone numbers /
carrier gateway / vacations and the old `calendar_run_day_of_month` from
the server's settings.json — they had lingered, unread, since the pivots.
- **No vacation/no-push date ranges (removed 2026-09-03, briefly existed).**
A day with no posted menu already produces an empty `items` list from
`get_month_menu()`, and `_push_calendar()` already skips pushing when
@ -77,53 +81,51 @@ pre-2026-09-08 app.
does anything externally-visible. On the HA side
(`automation.lunch_menu_family_calendar`, `http://192.168.86.207:8123`),
a `local_only` webhook trigger runs `calendar.create_event` against
`calendar.family_2` (the "Family" Google Calendar entity — there are two
entities both named "Family" in this HA instance, `calendar.family` and
`calendar.family_2`; this integration deliberately targets `_2` per user
choice). `calendar.family_2` only supports `create_event`, not
`delete_event` — a bad push can't be cleaned up via the HA API, only
manually in Google Calendar. No duplicate-prevention: re-pushing the
same date's menu creates a second event — this is why the default
schedule is a single day/month, not daily (see below).
**`mode: parallel`, not the default `single` (fixed 2026-09-03 after a
real bug):** a push fires one webhook POST per school day back-to-back
with no delay. Under `mode: single`, HA rejects any webhook trigger that
arrives while a previous run is still executing (`calendar.create_event`
itself takes ~600-700ms) — most days in a batch were silently dropped as
`failed_single`, so only one day's event ever actually got created per
push. Found via `ha_get_automation_traces` after the user reported a
manual test push not showing up on the calendar. Each invocation is
independent (one `calendar.create_event` call, no shared state), so
`parallel` is the correct mode per HA's own guidance ("independent
per-entity actions → parallel"), not just a workaround. The webhook id is
`calendar.family_2` (the shared iCloud "Family" calendar, via CalDAV —
there are two entities both named "Family" in this HA instance,
`calendar.family` and `calendar.family_2`; this integration deliberately
targets `_2` per user choice). `calendar.family_2` supports
`create_event` and `get_events` but not `delete_event` — a bad push can't
be cleaned up via the HA API, only by hand in the Calendar app.
**Automation mode history — `queued` since 2026-09-29.** The app fires
one webhook POST per school day back-to-back (HA answers each POST
immediately, so the app never waits on the calendar write). `single`
(default) dropped all but the first as `failed_single` (fixed 2026-09-03
→ `parallel`); `parallel` then sent ~20 simultaneous CalDAV PUTs on a
month push, overflowing iCloud (connection pool full + ~10× `500
Internal Server Error` on the 2026-09-25 push). Now `queued` (max 40):
one write at a time, nothing dropped. **Dedup (2026-09-29):** before
creating, the automation reads the date with `calendar.get_events` and
stops if a `🍱 Mariposa Lunch` event already exists — that's what makes
daily pulls safe. It verifies the event saved and retries once after 15s.
Pre-dedup pushes left 2-3 copies on most early-September days; those
have to be deleted by hand. The webhook id is
a random token that lives only in the server's `.env` (`HA_WEBHOOK_URL`)
and in the automation's trigger config — never committed here. If the
calendar push stops working, check both ends: `GET /api/integrations`
on this app reports whether `HA_WEBHOOK_URL` is set at all, and
`ha_get_automation_traces` on `automation.lunch_menu_family_calendar`
shows whether HA is even receiving the webhook.
- **Monthly pull scheduler (weekly → monthly, 2026-09-08):** calendar pulls
run on their own schedule (`calendar_run_time`/`calendar_run_day_of_month`
in settings, `schedule` jobs tagged `"calendar"`), completely decoupled
from anything else — the tag/isolation pattern
(`schedule.clear("calendar")` instead of a bare `schedule.clear()`) is
deliberate so a future second schedule wouldn't stomp this one. The
`schedule` library has no native day-of-month trigger, so
`apply_calendar_schedule()` registers a *daily* job
(`_maybe_push_month`) that no-ops unless `datetime.now().day` matches
the configured day — `push_month_to_calendar()` only actually runs on
that one day per month. `next_calendar_run()` computes the real next
firing directly from settings (handling month/year rollover and clamping
the configured day to short months, e.g. day 30 in February → the 28th)
for the UI's "Next Scheduled" field, since the underlying `schedule` job's
own `next_run` is meaningless here (it's always just "tomorrow"). Default
is day 25 at 18:00 — picked because the district tends to post next
month's PDF a few days before month-end, so pulling then usually catches
the *upcoming* month rather than the one ending. Whatever month is live
at the permalink when the job fires is what gets pushed — there's no
logic forcing "next month specifically."
- **Daily pull scheduler (monthly → daily, 2026-09-29):** `push_month_to_calendar()`
runs every day at `calendar_run_time` (default 18:00; `schedule` job
tagged `"calendar"` — `schedule.clear("calendar")`, not a bare
`schedule.clear()`, so a future second schedule wouldn't stomp it).
**Why daily:** the old once-a-month day-25 pull pushed whatever month was
live at the permalink; when the district posted late (September 2026: the
permalink still served September on 09-29), it silently re-pushed the
current month and the next month's menu wouldn't be pulled until the
following 25th — most of that month gone. Daily pulls pick up a new month
the evening it's posted; HA-side dedup makes the repeats harmless, and the
Claude read costs well under a cent a day. Each pull pushes **only today
onward** (past dates are skipped — they're already on the calendar or no
longer matter) and **skips weekend dates that have items**, logging a
warning and noting it in the status result: there's never school lunch on
a weekend, so that's a misread date (seen once: Fri 9/25's menu landed on
Sat 9/26). Whatever month is live at the permalink is what gets read —
there's no logic forcing "next month specifically."
- **Manual actions:** "Push This Month to Calendar" (`POST /api/push-calendar`
→ `push_month_to_calendar()`, same function the schedule calls) and
→ `push_month_to_calendar()`, same function the schedule calls — so it
also pushes only today onward and skips weekend misreads) and
"Preview This Month's Menu" (`GET /api/preview` → `get_month_menu()`
directly, pure dry run, no Home Assistant push, no side effects at all —
use this to check the scraper before trusting a scheduled push).