lunch-notifier/CLAUDE.md
Barely Removable 1bc07a7ad0
All checks were successful
Deploy / deploy (push) Successful in 44s
Wire up container timezone via /etc/localtime bind mount
Container defaulted to UTC (no TZ configured), so calendar_run_time and
the scraper's date-of-week logic didn't match the user's local clock.
Uncomments the existing /etc/localtime mount and installs tzdata so the
host's zoneinfo symlink actually resolves instead of silently falling
back to UTC.

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

154 lines
9.2 KiB
Markdown

# 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).
**`mode: parallel`, not the default `single` (fixed 2026-09-03 after a
real bug):** a week 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) — 4 of every 5 days
were silently dropped as `failed_single`, so only one day's event ever
actually got created per week 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.
- **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
```bash
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.
**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.