lunch-notifier/CLAUDE.md
Barely Removable 1c31ec780b
All checks were successful
Deploy / deploy (push) Successful in 36s
Pivot from SMS to calendar-only: weekly pull, independent schedule
Deletes the entire SMS path at the user's request — Gmail SMTP, phone
number management, carrier gateway settings, and the daily SMS+calendar
job are all gone. Home Assistant calendar events are now the only output.

- scraper.py: get_week_menu() returns the whole school week in one call
  (the Nutrislice API is week-granular regardless of the date requested);
  drops the now-unused single-day get_lunch_menu() and the send_sms()/
  CLI entrypoint that only the SMS path used.
- app.py: push_week_to_calendar() fetches the week and pushes every day
  with a posted menu; runs on its own schedule (calendar_run_time/
  calendar_run_days, tagged "calendar" jobs) fully decoupled from
  anything else, since neither calendar target supports delete-based
  dedup and double-firing would create duplicate events. Manual actions
  become "Push This Week to Calendar" and "Preview This Week's Menu"
  (now a real dry-run over the whole week, not just today).
- Deleted the vacation/no-push date range feature: a day with no posted
  menu already produces empty items, and pushing already skips empty
  items — the extra vacation-range layer for suppressing days the school
  *did* post a menu for was judged unnecessary and removed along with
  its UI, settings field, and API endpoints.
- Deleted scheduler.py, a dead alternate entrypoint (unused by Dockerfile/
  docker-compose) that only worked via the now-deleted scraper.main().
- load_settings() now merges the file over _DEFAULTS instead of returning
  it verbatim — the server's existing settings.json predates this schema
  and has neither calendar_run_time nor calendar_run_days, which would
  otherwise silently schedule zero jobs after this deploys. Verified with
  a migration test using the server's actual old-schema settings shape.

All of this was exercised for real: Docker builds, live containers against
a mock webhook (single-day and full-week pushes, per-entity dedup
behavior), a migration test with the pre-existing settings.json shape,
and a Chrome pass over the rebuilt UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
2026-09-03 09:10:36 -07:00

7.2 KiB

Lunch Notifier

Single-file Flask app that scrapes the weekly 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.

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 and parses the school's published lunch menu from Nutrislice's API. get_week_menu(date_str) returns every day in the API's week-granular response as [{"date": ..., "items": [...]}, ...] (the API returns a full week regardless of which single date you ask for). Days with no posted menu (weekends, breaks, holidays) come back with an empty items list rather than being omitted.
  • Settings (calendar pull schedule) persist to /data/settings.json, not environment variables — env vars (CALENDAR_RUN_TIME, CALENDAR_RUN_DAYS) 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 this schema (it's from the SMS era) and lacks calendar_run_time/calendar_run_days entirely; 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. Verified via a migration test (old-schema settings.json + fresh container → correct defaults filled in, next_calendar_run populated).
  • 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_week_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 now required, not optional — 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/week, not daily (see below). 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.
  • Independent weekly-pull scheduler (2026-09-03): calendar pulls run on their own schedule (calendar_run_time/calendar_run_days in settings, schedule jobs tagged "calendar"), completely decoupled from any other job — there's nothing else in this app anymore, but 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. push_week_to_calendar() fetches the whole current school week and pushes every day with a posted menu. Default is Sunday 18:00 (one firing/week) — the UI warns that picking more than one day/week creates duplicate events for days already covered, since there's no dedup.
  • Manual actions: "Push This Week to Calendar" (POST /api/push-calendar → push_week_to_calendar(), same function the schedule calls) and "Preview This Week's Menu" (GET /api/preview → get_week_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 HA_WEBHOOK_URL set to actually push anywhere; without it 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 is server-only, gitignored, never committed.

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.