Pivot from SMS to calendar-only: weekly pull, independent schedule
All checks were successful
Deploy / deploy (push) Successful in 36s

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
This commit is contained in:
Barely Removable 2026-09-03 09:10:36 -07:00
parent d3498849d3
commit 1c31ec780b
6 changed files with 231 additions and 646 deletions

137
CLAUDE.md
View file

@ -1,57 +1,82 @@
# Lunch Notifier
Single-file Flask app that scrapes the daily Mariposa (Kyrene school
district) lunch menu and texts it to a list of phone numbers via
email-to-SMS carrier gateways (e.g. `4805551234@vtext.com` for Verizon).
Runs as one Docker container on the user's Unraid home server.
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, APScheduler-style job
scheduling (`schedule` library), and the email-to-SMS send logic
(`smtplib` via Gmail, app password auth). The HTML/CSS/JS frontend is
embedded as a Python string constant (`HTML`) rather than a separate
- **`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.
- **`scheduler.py`** — day-of-week/time scheduling helpers used by `app.py`.
- Settings (phone numbers, send time, days, vacation date ranges) persist
to `/data/settings.json`, not environment variables — env vars
(`PHONE_NUMBER`, `RUN_TIME`, `RUN_DAYS`) are defaults used only the very
first time, before a settings file exists.
- Vacation ranges suppress sends without disabling the schedule — checked
fresh on every run via `is_on_vacation()`.
- **Home Assistant integration (2026-09-02, HA side built 2026-09-03):** if
`HA_WEBHOOK_URL` is set, every successful menu fetch also POSTs
`{"date": ..., "items": [...]}` to that webhook, independent of whether
the SMS send itself succeeds. 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`), producing an all-day event with the menu as
the description (bulleted). This makes the scrape here the single
source of truth — HA doesn't poll or re-scrape anything.
`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 either way: re-pushing the same
date's menu creates a second event. 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
- **`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.
- **Manual actions (2026-09-03):** besides "Send Lunch Menu Now" (full job —
scrape, HA push, text everyone), the UI has "Push to Calendar Only"
(`POST /api/push-calendar` → `push_calendar_only()`, scrape + HA push, no
texts — for testing the calendar sync without spamming phones) and
"Preview Today's Menu" (`GET /api/preview` → `_scrape_menu()`, pure dry
run, no side effects at all). `carrier_gateway` is editable in the
Schedule card (dropdown of common US carriers + custom domain). Gmail
credentials and `HA_WEBHOOK_URL` stay env-only, not settings.json — the
UI has no auth, so secrets aren't made editable there; `GET
/api/integrations` just exposes whether each is configured (booleans,
not values) so the UI can show a badge.
- **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
@ -60,10 +85,10 @@ pip install -r requirements.txt
python3 app.py
```
Needs `GMAIL_USER` and `GMAIL_APP_PASSWORD` env vars to actually send
(a Gmail **app password**, not the account password — required since Gmail
disallows plain SMTP auth with the real password). Visit
`http://localhost:2323`.
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
@ -79,13 +104,15 @@ 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`.
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 phone numbers, schedule,
and vacation ranges — this is live user data, not deploy-managed. `.env`
holds `GMAIL_USER`/`GMAIL_APP_PASSWORD`/`PHONE_NUMBER` etc. and is
server-only, gitignored, never committed.
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.