diff --git a/.env.example b/.env.example index 4ccf445..0eb457e 100644 --- a/.env.example +++ b/.env.example @@ -1,30 +1,20 @@ # Copy this to .env and fill in your values # --- Required --- -# 10-digit phone number (no + or country code) -PHONE_NUMBER=4805405363 - -# Your carrier's SMS email gateway -# Verizon: vtext.com | T-Mobile: tmomail.net | AT&T: txt.att.net | Cricket: sms.cricketwireless.net -CARRIER_GATEWAY=vtext.com - -# Gmail address to send from -GMAIL_USER=you@gmail.com - -# Gmail App Password (myaccount.google.com → Security → App passwords) -# Remove spaces from the 16-char code -GMAIL_APP_PASSWORD=xxxxxxxxxxxxxxxxxxxx - -# --- Optional --- -# Home Assistant webhook URL to push the day's menu to (for a calendar-event -# automation). Leave unset to skip. e.g. http://192.168.86.X:8123/api/webhook/ +# Home Assistant webhook URL that receives the day's menu and creates a +# calendar event (see automation.lunch_menu_family_calendar in HA). +# e.g. http://192.168.86.X:8123/api/webhook/ HA_WEBHOOK_URL= -# Time to send the text each day (24-hour HH:MM, container timezone) -RUN_TIME=07:00 +# --- Optional --- +# Day(s) of week to pull the upcoming school week and push it to the +# calendar (comma-separated: mon,tue,wed,thu,fri,sat,sun). Picking more +# than one day creates duplicate events for days already pushed — most +# people want just one. +CALENDAR_RUN_DAYS=sun -# Days to run (comma-separated: mon,tue,wed,thu,fri,sat,sun) -RUN_DAYS=mon,tue,wed,thu,fri +# Time to run the pull (24-hour HH:MM, container timezone) +CALENDAR_RUN_TIME=18:00 -# HTTP trigger server port +# HTTP server port PORT=2323 diff --git a/CLAUDE.md b/CLAUDE.md index 88f5806..2555812 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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. diff --git a/Dockerfile b/Dockerfile index f5393a1..efd7d0b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,7 +1,7 @@ FROM python:3.12-slim LABEL org.opencontainers.image.title="🍱 Lunch Notifier" -LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu → SMS" +LABEL org.opencontainers.image.description="Kyrene de la Mariposa lunch menu → Home Assistant calendar" LABEL org.opencontainers.image.url="http://localhost:2323" LABEL org.opencontainers.image.icon="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" LABEL net.unraid.docker.icon="http://192.168.86.33:2323/icon.png" diff --git a/app.py b/app.py index 72bf798..b207e17 100644 --- a/app.py +++ b/app.py @@ -1,22 +1,20 @@ """ -Lunch Notifier — web UI + scheduler combined. -Visit http://localhost:2323 to manage settings. +Lunch Notifier — pulls the Mariposa lunch menu and pushes it to a Home +Assistant calendar. Visit http://localhost:2323 to manage settings. """ import json import logging import os -import smtplib import threading import time -from datetime import date, datetime -from email.message import EmailMessage +from datetime import datetime from pathlib import Path import requests import schedule from flask import Flask, jsonify, request -from scraper import get_lunch_menu +from scraper import get_week_menu logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") log = logging.getLogger(__name__) @@ -28,20 +26,11 @@ PORT = int(os.environ.get("PORT", "2323")) # Env-var defaults (used only when no settings file exists yet) _DEFAULTS = { - "phone_numbers": [p for p in [os.environ.get("PHONE_NUMBER", "")] if p], - "carrier_gateway": os.environ.get("CARRIER_GATEWAY", "vtext.com"), - "run_time": os.environ.get("RUN_TIME", "07:00"), - "run_days": [d.strip() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",") if d.strip()], - "vacations": [], + "calendar_run_time": os.environ.get("CALENDAR_RUN_TIME", "18:00"), + "calendar_run_days": [d.strip() for d in os.environ.get("CALENDAR_RUN_DAYS", "sun").split(",") if d.strip()], } -_status = { - "last_run": None, - "last_result": None, - "last_message": None, - "calendar_result": None, - "last_calendar_run": None, -} +_status = {"last_calendar_run": None, "calendar_result": None} _lock = threading.Lock() @@ -51,7 +40,11 @@ def load_settings() -> dict: if SETTINGS_FILE.exists(): try: with open(SETTINGS_FILE) as f: - return json.load(f) + # Merge over _DEFAULTS so a settings.json from before a schema + # change (e.g. the pre-calendar SMS-era file) still gets + # calendar_run_time/calendar_run_days rather than silently + # scheduling nothing. + return {**_DEFAULTS, **json.load(f)} except Exception: pass return dict(_DEFAULTS) @@ -63,61 +56,18 @@ def save_settings(s: dict) -> None: json.dump(s, f, indent=2) -def is_on_vacation(settings: dict) -> str | None: - today = date.today().isoformat() - for v in settings.get("vacations", []): - if v["start"] <= today <= v["end"]: - return v.get("label", "Vacation") - return None - - -# ── Core job ────────────────────────────────────────────────────────────────── - -def _send_email_sms(message: str, phone: str, gateway: str) -> None: - gmail_user = os.environ["GMAIL_USER"] - gmail_pass = os.environ["GMAIL_APP_PASSWORD"] - to_addr = f"{phone}@{gateway}" - msg = EmailMessage() - msg["From"] = gmail_user - msg["To"] = to_addr - msg.set_content(message) - log.info(f"Sending → {to_addr}") - with smtplib.SMTP("smtp.gmail.com", 587, timeout=15) as smtp: - smtp.starttls() - smtp.login(gmail_user, gmail_pass) - smtp.send_message(msg) - log.info(f"Sent ✓ → {to_addr}") - +# ── Calendar push ───────────────────────────────────────────────────────────── def _push_to_home_assistant(items: list[str], date_str: str) -> None: webhook_url = os.environ["HA_WEBHOOK_URL"] - log.info(f"Pushing menu → Home Assistant webhook") + log.info(f"Pushing {date_str} menu → Home Assistant webhook") resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10) resp.raise_for_status() - log.info("Home Assistant push ✓") - - -def _scrape_menu() -> tuple[list[str], str | None, str]: - """Fetch the menu and build the display/SMS message. No side effects.""" - try: - items, date_str = get_lunch_menu() - except Exception as e: - log.exception("Menu fetch failed") - return [], None, f"Error fetching Mariposa lunch menu: {e}" - - if items: - body = "\n".join(f"• {i}" for i in items) - message = f"Mariposa Lunch {date_str}:\n{body}" - else: - message = f"No lunch menu posted for {date_str} (may be a break or holiday)." - - if len(message) > 1400: - message = message[:1397] + "..." - return items, date_str, message + log.info(f"Home Assistant push ✓ ({date_str})") def _push_calendar(items: list[str], date_str: str | None) -> str: - """Push to Home Assistant if configured. Returns a calendar_result status string.""" + """Push one day to Home Assistant if configured. Returns a status string.""" if not items: return "no items" if not os.environ.get("HA_WEBHOOK_URL"): @@ -126,57 +76,48 @@ def _push_calendar(items: list[str], date_str: str | None) -> str: _push_to_home_assistant(items, date_str) return "ok" except Exception as e: - log.error(f"Home Assistant push failed: {e}") + log.error(f"Home Assistant push failed ({date_str}): {e}") return f"error: {e}" -def run_job() -> None: - """Full daily job: scrape, push to HA calendar, and text everyone.""" +def push_week_to_calendar() -> None: + """Fetch the current school week and push every day that has a posted + menu — days with nothing posted (weekends, breaks, holidays) are + skipped automatically since there's nothing to create an event from. + Runs on its own schedule (calendar_run_time/calendar_run_days), + independent of anything else.""" global _status - settings = load_settings() - vac = is_on_vacation(settings) - if vac: - log.info(f"Skipping — vacation: {vac}") - _status = {**_status, "last_run": datetime.now().isoformat(), "last_result": f"skipped ({vac})"} + try: + week = get_week_menu() + except Exception as e: + log.exception("Week fetch failed") + _status = {**_status, "last_calendar_run": datetime.now().isoformat(), "calendar_result": f"error: {e}"} return - phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()] - gateway = settings.get("carrier_gateway", "vtext.com") + pushed = errors = no_items = 0 + for day in week: + result = _push_calendar(day["items"], day["date"]) + if result == "ok": + pushed += 1 + elif result == "no items": + no_items += 1 + else: + errors += 1 - items, date_str, message = _scrape_menu() - log.info(f"Message:\n{message}") + log.info(f"Week push: {pushed} pushed, {errors} errors, {no_items} no menu") - calendar_result = _push_calendar(items, date_str) - - errors = [] - for phone in phones: - try: - _send_email_sms(message, phone, gateway) - except Exception as e: - log.error(f"Failed to send to {phone}: {e}") - errors.append(str(e)) + if errors: + summary = f"{pushed} ok, {errors} error" + ("s" if errors != 1 else "") + elif pushed: + summary = f"ok ({pushed} day{'s' if pushed != 1 else ''})" + else: + summary = "no items" _status = { **_status, - "last_run": datetime.now().isoformat(), - "last_result": "error: " + "; ".join(errors) if errors else "ok", - "last_message": message, - "calendar_result": calendar_result, - "last_calendar_run": datetime.now().isoformat(), - } - - -def push_calendar_only() -> None: - """Scrape and push to Home Assistant without texting anyone.""" - global _status - items, date_str, message = _scrape_menu() - log.info(f"Calendar-only push — message:\n{message}") - calendar_result = _push_calendar(items, date_str) - _status = { - **_status, - "calendar_result": calendar_result, "last_calendar_run": datetime.now().isoformat(), + "calendar_result": summary, } @@ -193,15 +134,15 @@ DAY_MAP_FNS = { } -def apply_schedule(settings: dict) -> None: +def apply_calendar_schedule(settings: dict) -> None: with _lock: - schedule.clear() - run_time = settings.get("run_time", "07:00") - for day in settings.get("run_days", []): + schedule.clear("calendar") + run_time = settings.get("calendar_run_time", "18:00") + for day in settings.get("calendar_run_days", []): day = day.strip().lower() if day in DAY_MAP_FNS: - DAY_MAP_FNS[day]().at(run_time).do(run_job) - log.info(f"Scheduled: {day} at {run_time}") + DAY_MAP_FNS[day]().at(run_time).do(push_week_to_calendar).tag("calendar") + log.info(f"Scheduled calendar pull: {day} at {run_time}") def _schedule_loop() -> None: @@ -235,72 +176,42 @@ def api_get_settings(): def api_post_settings(): data = request.get_json(force=True) s = load_settings() - for key in ("phone_numbers", "carrier_gateway", "run_time", "run_days"): + for key in ("calendar_run_time", "calendar_run_days"): if key in data: s[key] = data[key] save_settings(s) - apply_schedule(s) - return jsonify({"ok": True}) - - -@app.route("/api/vacation", methods=["POST"]) -def api_add_vacation(): - data = request.get_json(force=True) - s = load_settings() - s.setdefault("vacations", []).append({ - "id": str(int(time.time() * 1000)), - "start": data["start"], - "end": data["end"], - "label": data.get("label", "Vacation"), - }) - save_settings(s) - return jsonify({"ok": True}) - - -@app.route("/api/vacation/", methods=["DELETE"]) -def api_delete_vacation(vid: str): - s = load_settings() - s["vacations"] = [v for v in s.get("vacations", []) if str(v.get("id")) != vid] - save_settings(s) - return jsonify({"ok": True}) - - -@app.route("/api/send", methods=["POST"]) -def api_send(): - threading.Thread(target=run_job, daemon=True).start() + apply_calendar_schedule(s) return jsonify({"ok": True}) @app.route("/api/push-calendar", methods=["POST"]) def api_push_calendar(): - threading.Thread(target=push_calendar_only, daemon=True).start() + threading.Thread(target=push_week_to_calendar, daemon=True).start() return jsonify({"ok": True}) @app.route("/api/preview") def api_preview(): - """Dry-run scrape: no SMS, no Home Assistant push.""" - items, date_str, message = _scrape_menu() - return jsonify({"date": date_str, "items": items, "message": message}) + """Dry-run week scrape: no Home Assistant push, no side effects.""" + try: + week = get_week_menu() + except Exception as e: + return jsonify({"error": str(e)}), 502 + return jsonify({"days": week}) @app.route("/api/integrations") def api_integrations(): - return jsonify({ - "sms_configured": bool(os.environ.get("GMAIL_USER") and os.environ.get("GMAIL_APP_PASSWORD")), - "ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL")), - }) + return jsonify({"ha_webhook_configured": bool(os.environ.get("HA_WEBHOOK_URL"))}) @app.route("/api/status") def api_status(): - s = load_settings() with _lock: - next_runs = sorted([j.next_run for j in schedule.get_jobs() if j.next_run]) + next_runs = sorted([j.next_run for j in schedule.get_jobs("calendar") if j.next_run]) return jsonify({ **_status, - "next_run": next_runs[0].isoformat() if next_runs else None, - "on_vacation": is_on_vacation(s), + "next_calendar_run": next_runs[0].isoformat() if next_runs else None, }) @@ -366,7 +277,7 @@ header p { color: var(--muted); font-size: .875rem; margin-top: 2px; } .field:last-child { margin-bottom: 0; } label.field-label { display: block; font-size: .8rem; font-weight: 600; color: var(--text); margin-bottom: 6px; } -input[type=text], input[type=time], input[type=date] { +input[type=text], input[type=time] { width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px; font-size: .875rem; color: var(--text); background: var(--card); } @@ -377,17 +288,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co font-size: .8rem; font-weight: 600; cursor: pointer; background: var(--card); transition: all .15s; user-select: none; } .day-chip.on { background: var(--primary); color: #fff; border-color: var(--primary); } -/* Lists */ -.item-list { list-style: none; } -.item-row { display: flex; align-items: center; gap: 10px; padding: 10px 0; - border-bottom: 1px solid var(--border); } -.item-row:last-child { border-bottom: none; } -.item-main { flex: 1; } -.item-title { font-size: .875rem; font-weight: 500; } .item-sub { font-size: .75rem; color: var(--muted); margin-top: 1px; } -/* Add row */ -.add-row { display: flex; gap: 8px; align-items: flex-end; margin-top: 14px; } -.add-row .field { flex: 1; margin-bottom: 0; } /* Divider */ .divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; } /* Toast */ @@ -396,8 +297,11 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co font-size: .875rem; font-weight: 500; opacity: 0; transition: opacity .25s, transform .25s; pointer-events: none; white-space: nowrap; z-index: 100; } .toast.show { opacity: 1; transform: translateX(-50%) translateY(0); } -.empty-state { color: var(--muted); font-size: .875rem; padding: 6px 0; } -.row-2 { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; } +.week-day { padding: 8px 0; border-bottom: 1px solid var(--border); } +.week-day:last-child { border-bottom: none; } +.week-day-date { font-size: .8rem; font-weight: 600; margin-bottom: 3px; } +.week-day-items { font-size: .8rem; color: #c9d1d9; white-space: pre-wrap; } +.week-day-empty { font-size: .8rem; color: var(--muted); font-style: italic; } @@ -407,7 +311,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co Chip the Challenger

Lunch Notifier

-

Kyrene de la Mariposa · Automated lunch menu texts

+

Kyrene de la Mariposa · Menu → Home Assistant calendar

@@ -416,108 +320,48 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
Status
-
Last Run
—
+
Last Pull
—
Result
—
Next Scheduled
—
-
Mode
—
-
-
Manual Actions
- -
- - -
- + +
Integrations
-
SMS (Gmail)
—
Home Assistant
—
-
Configured via .env on the server, not here.
+
Configured via .env (HA_WEBHOOK_URL) on the server, not here.
-
Schedule
+
Calendar Pull Schedule
- +
- +
-
-
- - - +
Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed — most people want just one (e.g. Sunday evening).

- - -
-
Phone Numbers
-
    -
    -
    - - -
    - -
    -
    - - -
    -
    Vacation / No-Send Periods
    -
      -
      -
      - - -
      -
      -
      - - -
      -
      - - -
      -
      - -
      @@ -539,18 +383,6 @@ async function load() { applyIntegrations(ig); } -const KNOWN_GATEWAYS = ['vtext.com','txt.att.net','tmomail.net','messaging.sprintpcs.com', - 'sms.myboostmobile.com','sms.cricketwireless.net','email.uscc.net','msg.fi.google.com']; - -function applyIntegrations(ig) { - document.getElementById('i-sms').innerHTML = ig.sms_configured - ? 'Configured' - : 'Not configured'; - document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured - ? 'Configured' - : 'Not configured'; -} - function fmt(iso) { if (!iso) return '—'; const d = new Date(iso); @@ -558,155 +390,46 @@ function fmt(iso) { d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'}); } +function applyIntegrations(ig) { + document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured + ? 'Configured' + : 'Not configured'; +} + function applySettings(s) { - document.getElementById('f-time').value = s.run_time || '07:00'; - // Carrier gateway - const gw = s.carrier_gateway || 'vtext.com'; - const gwSel = document.getElementById('f-gateway'); - const gwCustom = document.getElementById('f-gateway-custom'); - if (KNOWN_GATEWAYS.includes(gw)) { - gwSel.value = gw; - gwCustom.style.display = 'none'; - gwCustom.value = ''; - } else { - gwSel.value = '__custom__'; - gwCustom.style.display = ''; - gwCustom.value = gw; - } - // Days + document.getElementById('f-time').value = s.calendar_run_time || '18:00'; const row = document.getElementById('days-row'); row.innerHTML = DAYS.map(d => - `${DAY_LABELS[d]}` ).join(''); - // Phones - const pl = document.getElementById('phone-list'); - if (!(s.phone_numbers||[]).length) { - pl.innerHTML = '
    • No numbers added yet.
    • '; - } else { - pl.innerHTML = (s.phone_numbers||[]).map((p,i) => ` -
    • -
      ${p}
      - -
    • `).join(''); - } - // Vacations - const vl = document.getElementById('vac-list'); - if (!(s.vacations||[]).length) { - vl.innerHTML = '
    • No vacation periods set.
    • '; - } else { - vl.innerHTML = (s.vacations||[]).map(v => ` -
    • -
      -
      ${v.label}
      -
      ${v.start} → ${v.end}
      -
      - -
    • `).join(''); - } } function applyStatus(st) { - document.getElementById('s-last-run').textContent = fmt(st.last_run); - const res = st.last_result; - let cls = res==='ok' ? 'badge-green' : res?.startsWith('skipped') ? 'badge-yellow' : res ? 'badge-red' : ''; + document.getElementById('s-last-run').textContent = fmt(st.last_calendar_run); + const res = st.calendar_result; + let cls = res==='ok' || res?.startsWith('ok') ? 'badge-green' + : res?.startsWith('skipped') || res === 'no items' ? 'badge-yellow' + : res ? 'badge-red' : ''; document.getElementById('s-result').innerHTML = res ? `${res}` : '—'; - document.getElementById('s-next').textContent = fmt(st.next_run); - const vac = st.on_vacation; - document.getElementById('s-mode').innerHTML = vac - ? `Vacation: ${vac}` - : 'Active'; - const calStat = document.getElementById('s-cal-stat'); - const cal = st.calendar_result; - if (cal) { - calStat.style.display = ''; - const calCls = cal === 'ok' ? 'badge-green' : cal.startsWith('error') ? 'badge-red' : 'badge-yellow'; - const when = st.last_calendar_run ? `
      ${fmt(st.last_calendar_run)}
      ` : ''; - document.getElementById('s-cal').innerHTML = `${cal}${when}`; - } else { - calStat.style.display = 'none'; - } - const msgEl = document.getElementById('s-last-msg'); - if (st.last_message) { - msgEl.textContent = st.last_message; - msgEl.style.display = ''; - } else { - msgEl.style.display = 'none'; - } -} - -function onGatewayChange() { - const sel = document.getElementById('f-gateway'); - document.getElementById('f-gateway-custom').style.display = sel.value === '__custom__' ? '' : 'none'; + document.getElementById('s-next').textContent = fmt(st.next_calendar_run); } async function saveSchedule() { - const run_time = document.getElementById('f-time').value; - const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day); - const gwSel = document.getElementById('f-gateway').value; - const carrier_gateway = gwSel === '__custom__' - ? document.getElementById('f-gateway-custom').value.trim() - : gwSel; - if (!carrier_gateway) { toast('Enter a carrier gateway domain'); return; } - await post('/api/settings', {run_time, run_days, carrier_gateway}); + const calendar_run_time = document.getElementById('f-time').value; + const calendar_run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day); + await post('/api/settings', {calendar_run_time, calendar_run_days}); toast('Schedule saved'); load(); } -async function addPhone() { - const input = document.getElementById('f-phone'); - const phone = input.value.trim().replace(/\D/g,''); - if (!phone) { toast('Enter a 10-digit number'); return; } - const phone_numbers = [...(cfg.phone_numbers||[]), phone]; - await post('/api/settings', {phone_numbers}); - input.value = ''; - toast('Number added'); - load(); -} - -async function removePhone(idx) { - const phone_numbers = (cfg.phone_numbers||[]).filter((_,i)=>i!==idx); - await post('/api/settings', {phone_numbers}); - toast('Number removed'); - load(); -} - -async function addVacation() { - const label = document.getElementById('f-vac-label').value.trim() || 'Vacation'; - const start = document.getElementById('f-vac-start').value; - const end = document.getElementById('f-vac-end').value; - if (!start || !end) { toast('Set both start and end dates'); return; } - if (end < start) { toast('End must be after start'); return; } - await post('/api/vacation', {label, start, end}); - document.getElementById('f-vac-label').value = ''; - document.getElementById('f-vac-start').value = ''; - document.getElementById('f-vac-end').value = ''; - toast('Vacation period added'); - load(); -} - -async function removeVacation(id) { - await fetch(`/api/vacation/${id}`, {method:'DELETE'}); - toast('Removed'); - load(); -} - -async function sendNow() { - const btn = document.getElementById('send-btn'); - btn.disabled = true; - btn.textContent = 'Sending…'; - await post('/api/send', {}); - toast('Triggered! Check status in a moment.'); - setTimeout(() => { btn.disabled=false; btn.textContent='Send Lunch Menu Now'; load(); }, 6000); -} - async function pushCalendar() { const btn = document.getElementById('push-cal-btn'); btn.disabled = true; btn.textContent = 'Pushing…'; await post('/api/push-calendar', {}); - toast('Pushed! Check status in a moment.'); - setTimeout(() => { btn.disabled=false; btn.textContent='Push to Calendar Only'; load(); }, 6000); + toast('Triggered! Check status in a moment.'); + setTimeout(() => { btn.disabled=false; btn.textContent='Push This Week to Calendar'; load(); }, 6000); } async function previewMenu() { @@ -717,11 +440,21 @@ async function previewMenu() { try { const r = await fetch('/api/preview'); const data = await r.json(); - box.textContent = data.message; + if (data.error) { + box.innerHTML = `
      Error: ${data.error}
      `; + } else { + box.innerHTML = (data.days||[]).map(d => ` +
      +
      ${d.date}
      + ${d.items.length + ? `
      ${d.items.map(i=>'• '+i).join('\\n')}
      ` + : '
      No menu posted
      '} +
      `).join(''); + } box.style.display = ''; } finally { btn.disabled = false; - btn.textContent = "Preview Today's Menu"; + btn.textContent = "Preview This Week's Menu"; } } @@ -752,7 +485,7 @@ if __name__ == "__main__": log.info(f"Creating default settings at {SETTINGS_FILE}") save_settings(_DEFAULTS) - apply_schedule(load_settings()) + apply_calendar_schedule(load_settings()) threading.Thread(target=_schedule_loop, daemon=True).start() log.info(f"Web UI → http://localhost:{PORT}") app.run(host="0.0.0.0", port=PORT, debug=False, use_reloader=False) diff --git a/scheduler.py b/scheduler.py deleted file mode 100644 index 2ca9bcf..0000000 --- a/scheduler.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -Runs scraper.py on a schedule inside the container. - -HTTP trigger server on PORT (default 2323): - GET / → health check / status - POST /send → trigger scraper immediately - -Env vars: - PHONE_NUMBER – destination phone (required) - TEXTBELT_KEY – textbelt key (default: "textbelt" free tier) - RUN_TIME – HH:MM 24h time to fire daily (default: 07:00) - RUN_DAYS – comma-sep days: mon,tue,wed,thu,fri (default) - PORT – HTTP port (default: 2323) -""" -import logging -import os -import threading -import time -from http.server import BaseHTTPRequestHandler, HTTPServer - -import schedule - -from scraper import main as run_scraper # now a plain sync function - -logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s") -log = logging.getLogger(__name__) - -RUN_TIME = os.environ.get("RUN_TIME", "07:00") -RUN_DAYS = [d.strip().lower() for d in os.environ.get("RUN_DAYS", "mon,tue,wed,thu,fri").split(",")] -PORT = int(os.environ.get("PORT", "2323")) - -DAY_MAP = { - "mon": schedule.every().monday, - "tue": schedule.every().tuesday, - "wed": schedule.every().wednesday, - "thu": schedule.every().thursday, - "fri": schedule.every().friday, - "sat": schedule.every().saturday, - "sun": schedule.every().sunday, -} - -_last_run: str = "never" -_last_result: str = "n/a" - - -def job(): - global _last_run, _last_result - log.info("Running scheduled lunch menu check...") - try: - run_scraper() - _last_result = "ok" - except SystemExit: - _last_result = "sms_failed" - except Exception as e: - log.exception("Scraper failed") - _last_result = f"error: {e}" - _last_run = time.strftime("%Y-%m-%d %H:%M:%S") - - -class Handler(BaseHTTPRequestHandler): - def log_message(self, fmt, *args): - log.info("HTTP %s", fmt % args) - - def _respond(self, code: int, body: str): - data = body.encode() - self.send_response(code) - self.send_header("Content-Type", "text/plain") - self.send_header("Content-Length", str(len(data))) - self.end_headers() - self.wfile.write(data) - - def do_GET(self): - if self.path == "/": - status = ( - f"lunch-notifier running\n" - f"schedule : {', '.join(RUN_DAYS)} at {RUN_TIME}\n" - f"phone : {os.environ.get('PHONE_NUMBER', 'NOT SET')}\n" - f"last run : {_last_run}\n" - f"last result: {_last_result}\n\n" - f"POST /send to trigger immediately" - ) - self._respond(200, status) - else: - self._respond(404, "not found") - - def do_POST(self): - if self.path == "/send": - log.info("Manual trigger via HTTP POST /send") - threading.Thread(target=job, daemon=True).start() - self._respond(200, "Triggered — check container logs for result.") - else: - self._respond(404, "not found") - - -def start_http_server(): - server = HTTPServer(("0.0.0.0", PORT), Handler) - log.info(f"HTTP trigger server listening on port {PORT}") - server.serve_forever() - - -# ── Schedule setup ──────────────────────────────────────────────────────────── -for day in RUN_DAYS: - if day in DAY_MAP: - DAY_MAP[day].at(RUN_TIME).do(job) - log.info(f"Scheduled: {day} at {RUN_TIME}") - else: - log.warning(f"Unknown day '{day}', skipping.") - -log.info(f"Phone: {os.environ.get('PHONE_NUMBER', 'NOT SET')}") - -threading.Thread(target=start_http_server, daemon=True).start() - -while True: - schedule.run_pending() - time.sleep(30) diff --git a/scraper.py b/scraper.py index 4758a31..911bb41 100644 --- a/scraper.py +++ b/scraper.py @@ -1,9 +1,5 @@ -import os -import sys import logging -import smtplib from datetime import datetime -from email.message import EmailMessage import requests @@ -15,10 +11,7 @@ MENU_TYPE = "lunch" API_BASE = "https://kyrene.api.nutrislice.com/menu/api/weeks/school" -def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]: - if date_str is None: - date_str = datetime.now().strftime("%Y-%m-%d") - +def _fetch_week(date_str: str) -> dict: dt = datetime.strptime(date_str, "%Y-%m-%d") url = f"{API_BASE}/{SCHOOL_SLUG}/menu-type/{MENU_TYPE}/{dt.year}/{dt.month:02d}/{dt.day:02d}/" log.info(f"Fetching {url}") @@ -29,73 +22,30 @@ def get_lunch_menu(date_str: str | None = None) -> tuple[list[str], str]: timeout=20, ) resp.raise_for_status() - data = resp.json() + return resp.json() + +def _extract_items(day: dict) -> list[str]: items = [] - for day in data.get("days", []): - if day.get("date") != date_str: + for entry in day.get("menu_items", []): + if entry.get("is_section_title") or entry.get("is_station_header"): continue - for entry in day.get("menu_items", []): - if entry.get("is_section_title") or entry.get("is_station_header"): - continue - food = entry.get("food") - if food and food.get("name"): - items.append(food["name"].strip()) - - return items, date_str + food = entry.get("food") + if food and food.get("name"): + items.append(food["name"].strip()) + return items -def send_sms(message: str, phone: str) -> None: - gmail_user = os.environ["GMAIL_USER"] - gmail_pass = os.environ["GMAIL_APP_PASSWORD"] - gateway = os.environ.get("CARRIER_GATEWAY", "vtext.com") - to_addr = f"{phone}@{gateway}" +def get_week_menu(date_str: str | None = None) -> list[dict]: + """Returns every day in the school-published week containing date_str, + as [{"date": "YYYY-MM-DD", "items": [...]}, ...] in the API's own order. + Days with no posted menu (weekends, breaks, holidays) have an empty items list.""" + if date_str is None: + date_str = datetime.now().strftime("%Y-%m-%d") - msg = EmailMessage() - msg["From"] = gmail_user - msg["To"] = to_addr - msg.set_content(message) - - log.info(f"Sending email-to-SMS → {to_addr}") - with smtplib.SMTP("smtp.gmail.com", 587) as smtp: - smtp.starttls() - smtp.login(gmail_user, gmail_pass) - smtp.send_message(msg) - log.info("Sent.") - - -def main(): - phone = os.environ.get("PHONE_NUMBER", "").strip() - if not phone: - log.error("PHONE_NUMBER environment variable is not set.") - sys.exit(1) - - try: - items, date_str = get_lunch_menu() - if items: - body = "\n".join(f"• {i}" for i in items) - message = f"Mariposa Lunch {date_str}:\n{body}" - else: - message = ( - f"No lunch menu posted for {date_str} " - f"(Kyrene de la Mariposa — may be a break or holiday)." - ) - except Exception as e: - log.exception("Failed to fetch menu") - message = f"Error fetching Mariposa lunch menu: {e}" - - # SMS via email has a ~160 char limit per message segment; keep it short - if len(message) > 1400: - message = message[:1397] + "..." - - log.info(f"Message:\n{message}") - - try: - send_sms(message, phone) - except Exception as e: - log.error(f"Failed to send SMS: {e}") - sys.exit(1) - - -if __name__ == "__main__": - main() + data = _fetch_week(date_str) + return [ + {"date": day.get("date"), "items": _extract_items(day)} + for day in data.get("days", []) + if day.get("date") + ]