lunch-notifier/CLAUDE.md
Barely Removable a809aa5a5a
All checks were successful
Deploy / deploy (push) Successful in 35s
Switch menu source from Nutrislice to Claude-read PDF, weekly to monthly pull
The district discontinued Nutrislice in favor of a monthly PDF calendar
per grade band on kyrene.org. That PDF is a graphic calendar grid, not
reliably text-extractable, so scraper.py now sends it to Claude (vision +
structured JSON output) instead of hitting a JSON API. Fetches through a
stable Finalsite resource-manager permalink that always redirects to
whichever month is currently published, so no code change is needed each
month.

Also moves the pull schedule from weekly (day-of-week list) to monthly
(day-of-month), since the source itself is now monthly — defaults to the
25th, since the district tends to post next month's PDF a few days early.

Requires a new ANTHROPIC_API_KEY in the server's .env.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_013RbBoYm23muVyMZ18Ldqa6
2026-09-08 07:12:34 -07:00

12 KiB

Lunch Notifier

Single-file Flask app that scrapes the Mariposa (Kyrene school district) lunch menu and pushes each day to a Home Assistant calendar as an all-day event. Runs as one Docker container on the user's Unraid home server.

2026-09-03: pivoted from SMS to calendar-only. Originally texted phone numbers via email-to-SMS carrier gateways; that whole path (Gmail SMTP, phone number management, carrier gateway settings) was deleted at the 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-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 now. Anything describing a weekly Nutrislice pull describes the pre-2026-09-08 app.

Architecture

  • app.py — everything: Flask web UI/API, schedule-library job scheduling, and the Home Assistant webhook push. The HTML/CSS/JS frontend is embedded as a Python string constant (HTML) rather than a separate template file — no build step, no template engine.
  • scraper.py — fetches the currently-published K-5 lunch menu PDF and asks Claude (Anthropic API, claude-opus-5, structured JSON output via output_config.format) to read it into [{"date": ..., "items": [...]}, ...] for every day of whatever month is published. Days with no posted menu (weekends, breaks, holidays) come back with an empty items list rather than being omitted — same contract the old Nutrislice-based scraper used, so app.py's push logic didn't need to change.
  • Menu source (Nutrislice → PDF, 2026-09-08): the district's page links a stable Finalsite "resource manager" permalink (kyrene.org/fs/resource-manager/view/32bb3f8b-5a50-4b38-ad68-a5d3108de571 for the K-5/"Standard" menu — there's a different UUID per grade band/ campus on that page) that 302-redirects to whichever monthly PDF is currently live (scraper.MENU_PAGE_URL). Don't hardcode the PDF URL itself — it's a Finalsite CDN path that changes every month (verified: the September 2026 PDF and August's page both linked through the same permalink); only the permalink UUID is stable, and only changes if the district restructures that page entirely. The PDF itself is a single-page graphic calendar grid (mascots, colored cells, day numbers laid out visually) — not text-extractable in any reliable way (raw text extraction jumbles day numbers away from their menu items), which is why this reads it via Claude vision/document understanding rather than pdfplumber/ regex. Cost is negligible: one ~370KB single-page PDF per month, well under a cent of Opus 5 tokens.
  • ANTHROPIC_API_KEY is now required (server .env, alongside HA_WEBHOOK_URL) — scraper.get_month_menu() fails without it, same 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.
  • 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 items is empty — that's sufficient for "don't create an event for a day with nothing happening." A separate vacation-range setting to additionally suppress days the school did post a menu for was judged unnecessary complexity and deleted at the user's request; don't re-add it without being asked.
  • Home Assistant integration (built 2026-09-02/03, calendar-only pivot 2026-09-03): HA_WEBHOOK_URL is required — it's the only way this 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 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."
  • Manual actions: "Push This Month to Calendar" (POST /api/push-calendar → push_month_to_calendar(), same function the schedule calls) 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).

Local development

pip install -r requirements.txt
python3 app.py

Needs ANTHROPIC_API_KEY set for anything menu-related to work at all (including Preview, since that calls the same Claude-backed scraper) and HA_WEBHOOK_URL set to actually push anywhere — without the latter the app still runs and previews fine, GET /api/integrations just reports ha_webhook_configured: false and pushes resolve to "not configured". Visit http://localhost:2323.

Deployment

Target: Unraid server, container path /mnt/user/appdata/lunch-notifier, built from Dockerfile/docker-compose.yml in this repo. Container name Lunch-Notifier, port 2323. No reverse proxy — accessed directly via http://<server-ip>:2323 on the LAN.

Deploy mechanism: Forgejo push-to-deploy, same pattern as the other three projects on this box (card-grader, n64-tracker, arr-summary) — see card-grader's CLAUDE.md for the full pipeline mechanics (runner config, HTTPS-not-SSH git auth, known tradeoffs). In short: push to main on the forgejo remote (https://git.hippofam.com/ninja_hippo/lunch-notifier.git) triggers .forgejo/workflows/deploy.yml, which syncs into /mnt/user/appdata/lunch-notifier (excluding .git, .env, data/) and runs docker compose up -d --build. .env changes need an explicit docker compose up -d on the server afterward — env vars only load at container start, a deploy that doesn't change code won't pick up a manually-edited .env on its own.

Persistent state, never touched by deploys: data/settings.json (the volume-mounted /data inside the container) holds the calendar pull schedule — this is live user data, not deploy-managed. .env holds HA_WEBHOOK_URL and ANTHROPIC_API_KEY and is server-only, gitignored, never committed.

Container timezone (fixed 2026-09-08): the container previously had no timezone configured — python:3.12-slim defaults to UTC and the /etc/localtime bind mount in docker-compose.yml was commented out — so calendar_run_time (and the datetime.now() call in scraper.py that picks which week to fetch) were evaluated against UTC, not the user's local clock. This happened to be masked because Arizona (no DST) and Pacific Daylight Time are both UTC-7 right now, but would have silently drifted the actual fire time by an hour once PDT becomes PST in November. Fixed by uncommenting the /etc/localtime:/etc/localtime:ro bind mount and installing tzdata in the Dockerfile — without tzdata, the host's /etc/localtime (typically a symlink into /usr/share/zoneinfo/…) would dangle inside the container since that directory wouldn't exist, silently falling back to UTC again. Found while investigating why the first automatic Sunday calendar push after the 2026-09-03 pivot appeared not to run — that specific miss turned out to be unrelated (the school's Nutrislice menu was published empty for the whole week, so _push_calendar correctly skipped every day), but this latent TZ bug surfaced during the investigation and was worth fixing regardless.

Migration note (2026-08-25): this container originally used a Docker named volume (lunch-notifier_lunch-settings) instead of a bind mount. That meant Unraid's Appdata Backup plugin — which only knows about /mnt/user/appdata and /mnt/cache/appdata as backup sources — silently skipped it entirely; the settings data was never included in any backup. Migrated to a bind mount (./data:/data) with the existing settings copied over first and verified intact before the old named volume was deleted.