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

View file

@ -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/<id>
# 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/<random-token>
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

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.

View file

@ -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"

495
app.py
View file

@ -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/<vid>", 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; }
</style>
</head>
<body>
@ -407,7 +311,7 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<img src="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png" alt="Chip the Challenger" style="height:80px;width:auto;">
<div>
<h1>Lunch Notifier</h1>
<p>Kyrene de la Mariposa &middot; Automated lunch menu texts</p>
<p>Kyrene de la Mariposa &middot; Menu &rarr; Home Assistant calendar</p>
</div>
</div>
</header>
@ -416,108 +320,48 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
<div class="card">
<div class="card-label">Status</div>
<dl class="stat-grid">
<div class="stat"><dt>Last Run</dt><dd id="s-last-run">—</dd></div>
<div class="stat"><dt>Last Pull</dt><dd id="s-last-run">—</dd></div>
<div class="stat"><dt>Result</dt><dd id="s-result">—</dd></div>
<div class="stat"><dt>Next Scheduled</dt><dd id="s-next">—</dd></div>
<div class="stat"><dt>Mode</dt><dd id="s-mode">—</dd></div>
<div class="stat" id="s-cal-stat" style="display:none;"><dt>Calendar Sync</dt><dd id="s-cal">—</dd></div>
</dl>
<div id="s-last-msg" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
</div>
<!-- Manual Actions -->
<div class="card">
<div class="card-label">Manual Actions</div>
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
Send Lunch Menu Now
<button id="push-cal-btn" class="btn btn-primary btn-send" onclick="pushCalendar()">
Push This Week to Calendar
</button>
<div class="row-2" style="margin-top:10px;">
<button id="push-cal-btn" class="btn btn-ghost" onclick="pushCalendar()">
Push to Calendar Only
</button>
<button id="preview-btn" class="btn btn-ghost" onclick="previewMenu()">
Preview Today's Menu
</button>
</div>
<div id="preview-box" style="display:none;margin-top:14px;padding:10px 12px;background:#1c2128;border-radius:8px;font-size:.8rem;color:#c9d1d9;white-space:pre-wrap;"></div>
<button id="preview-btn" class="btn btn-ghost" style="width:100%;margin-top:10px;" onclick="previewMenu()">
Preview This Week's Menu
</button>
<div id="preview-box" style="display:none;margin-top:14px;"></div>
</div>
<!-- Integrations -->
<div class="card">
<div class="card-label">Integrations</div>
<dl class="stat-grid">
<div class="stat"><dt>SMS (Gmail)</dt><dd id="i-sms">—</dd></div>
<div class="stat"><dt>Home Assistant</dt><dd id="i-ha">—</dd></div>
</dl>
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> on the server, not here.</div>
<div class="item-sub" style="margin-top:10px;">Configured via <code>.env</code> (<code>HA_WEBHOOK_URL</code>) on the server, not here.</div>
</div>
<!-- Schedule -->
<div class="card">
<div class="card-label">Schedule</div>
<div class="card-label">Calendar Pull Schedule</div>
<div class="field">
<label class="field-label">Send Time</label>
<label class="field-label">Pull Time</label>
<input type="time" id="f-time" style="max-width:160px">
</div>
<div class="field">
<label class="field-label">Days</label>
<label class="field-label">Pull Days</label>
<div class="days-row" id="days-row"></div>
</div>
<div class="field">
<label class="field-label">Carrier Gateway</label>
<select id="f-gateway" onchange="onGatewayChange()" style="width:100%;padding:9px 12px;border:1px solid var(--border);border-radius:8px;font-size:.875rem;color:var(--text);background:var(--card);">
<option value="vtext.com">Verizon (vtext.com)</option>
<option value="txt.att.net">AT&amp;T (txt.att.net)</option>
<option value="tmomail.net">T-Mobile (tmomail.net)</option>
<option value="messaging.sprintpcs.com">Sprint (messaging.sprintpcs.com)</option>
<option value="sms.myboostmobile.com">Boost Mobile (sms.myboostmobile.com)</option>
<option value="sms.cricketwireless.net">Cricket (sms.cricketwireless.net)</option>
<option value="email.uscc.net">US Cellular (email.uscc.net)</option>
<option value="msg.fi.google.com">Google Fi (msg.fi.google.com)</option>
<option value="__custom__">Custom&hellip;</option>
</select>
<input type="text" id="f-gateway-custom" placeholder="your-carrier-gateway.com"
style="margin-top:8px;display:none;">
<div class="item-sub" style="margin-top:8px;">Each day fires a full week pull. Picking more than one day will create duplicate calendar events for days already pushed &mdash; most people want just one (e.g. Sunday evening).</div>
</div>
<hr class="divider">
<button class="btn btn-primary" onclick="saveSchedule()">Save Schedule</button>
</div>
<!-- Phone Numbers -->
<div class="card">
<div class="card-label">Phone Numbers</div>
<ul class="item-list" id="phone-list"></ul>
<div class="add-row">
<div class="field">
<label class="field-label">Add Number (10 digits)</label>
<input type="text" id="f-phone" placeholder="4805551234" maxlength="15"
onkeydown="if(event.key==='Enter') addPhone()">
</div>
<button class="btn btn-primary btn-sm" onclick="addPhone()">Add</button>
</div>
</div>
<!-- Vacation -->
<div class="card">
<div class="card-label">Vacation / No-Send Periods</div>
<ul class="item-list" id="vac-list"></ul>
<hr class="divider">
<div class="field">
<label class="field-label">Label</label>
<input type="text" id="f-vac-label" placeholder="Spring Break">
</div>
<div class="row-2">
<div class="field">
<label class="field-label">Start Date</label>
<input type="date" id="f-vac-start">
</div>
<div class="field">
<label class="field-label">End Date</label>
<input type="date" id="f-vac-end">
</div>
</div>
<button class="btn btn-primary" onclick="addVacation()">Add Period</button>
</div>
</div>
<div class="toast" id="toast"></div>
@ -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
? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-yellow">Not configured</span>';
document.getElementById('i-ha').innerHTML = ig.ha_webhook_configured
? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-yellow">Not configured</span>';
}
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
? '<span class="badge badge-green">Configured</span>'
: '<span class="badge badge-yellow">Not configured</span>';
}
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 =>
`<span class="day-chip ${(s.run_days||[]).includes(d)?'on':''}" data-day="${d}"
`<span class="day-chip ${(s.calendar_run_days||[]).includes(d)?'on':''}" data-day="${d}"
onclick="this.classList.toggle('on')">${DAY_LABELS[d]}</span>`
).join('');
// Phones
const pl = document.getElementById('phone-list');
if (!(s.phone_numbers||[]).length) {
pl.innerHTML = '<li class="empty-state">No numbers added yet.</li>';
} else {
pl.innerHTML = (s.phone_numbers||[]).map((p,i) => `
<li class="item-row">
<div class="item-main"><div class="item-title">${p}</div></div>
<button class="btn btn-danger btn-sm" onclick="removePhone(${i})">Remove</button>
</li>`).join('');
}
// Vacations
const vl = document.getElementById('vac-list');
if (!(s.vacations||[]).length) {
vl.innerHTML = '<li class="empty-state">No vacation periods set.</li>';
} else {
vl.innerHTML = (s.vacations||[]).map(v => `
<li class="item-row">
<div class="item-main">
<div class="item-title">${v.label}</div>
<div class="item-sub">${v.start} &rarr; ${v.end}</div>
</div>
<button class="btn btn-danger btn-sm" onclick="removeVacation('${v.id}')">Remove</button>
</li>`).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 ? `<span class="badge ${cls}">${res}</span>` : '—';
document.getElementById('s-next').textContent = fmt(st.next_run);
const vac = st.on_vacation;
document.getElementById('s-mode').innerHTML = vac
? `<span class="badge badge-yellow">Vacation: ${vac}</span>`
: '<span class="badge badge-green">Active</span>';
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 ? `<div class="item-sub">${fmt(st.last_calendar_run)}</div>` : '';
document.getElementById('s-cal').innerHTML = `<span class="badge ${calCls}">${cal}</span>${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 = `<div class="week-day-empty">Error: ${data.error}</div>`;
} else {
box.innerHTML = (data.days||[]).map(d => `
<div class="week-day">
<div class="week-day-date">${d.date}</div>
${d.items.length
? `<div class="week-day-items">${d.items.map(i=>'• '+i).join('\\n')}</div>`
: '<div class="week-day-empty">No menu posted</div>'}
</div>`).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)

View file

@ -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)

View file

@ -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")
]