lunch-notifier/CLAUDE.md
Barely Removable 600b8b097b
All checks were successful
Deploy / deploy (push) Successful in 31s
Let the HA iPad dashboard read /api/status
The wall dashboard's Ombi tab now shows a lunch panel with pull time and
next scheduled run. It's served from Home Assistant (:8123), a different
origin, so allow that one origin to read the one read-only endpoint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-09-29 11:00:39 -07:00

205 lines
12 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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-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 "Daily 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 (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
`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 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.
- **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."
- **iPad dashboard reads `/api/status` (2026-09-29).** The HA iPad wall
dashboard's Ombi tab shows a lunch panel: today's menu (read from the HA
calendar, not this app) plus pull time / next scheduled from
`/api/status`. That page is served from HA (`http://192.168.86.207:8123`),
a different origin, so `_cors_for_dashboard` adds
`Access-Control-Allow-Origin` for that one origin on that one read-only
endpoint. Changing `/api/status`'s shape (`next_calendar_run`) breaks
that panel; moving HA's address means updating `DASHBOARD_ORIGIN`.
- **Manual actions:** "Push This Month to Calendar" (`POST /api/push-calendar`
→ `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).
## Local development
```bash
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.