From 9c1112f28e1e52e9596e3febc673c6bf43005138 Mon Sep 17 00:00:00 2001 From: Barely Removable Date: Thu, 3 Sep 2026 07:18:12 -0700 Subject: [PATCH] Add carrier gateway field, calendar-only push, and menu preview to UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Surfaces the settings/actions that only existed at the API or env-var level: carrier_gateway was already an accepted setting but had no form field; HA webhook and Gmail config were invisible from the UI entirely. Adds a calendar-only push and a side-effect-free preview so the HA sync and scraper can be tested without texting everyone. Secrets stay env-only since the UI has no auth — only configured/not-configured status is exposed. Also fixes calendar_result reporting "ok" when HA_WEBHOOK_URL wasn't set at all, and stops the calendar-only push from clobbering the SMS job's last_run/last_result status fields. Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5 --- CLAUDE.md | 11 +++ app.py | 222 +++++++++++++++++++++++++++++++++++++++++++++--------- 2 files changed, 197 insertions(+), 36 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index aad396c..64f5afe 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -26,6 +26,17 @@ Runs as one Docker container on the user's Unraid home server. automation (webhook trigger → `calendar.create_event`) turns this into an all-day calendar event with the menu. This makes the scrape here the single source of truth — HA doesn't poll or re-scrape anything. +- **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. ## Local development diff --git a/app.py b/app.py index e2d750d..72bf798 100644 --- a/app.py +++ b/app.py @@ -35,7 +35,13 @@ _DEFAULTS = { "vacations": [], } -_status = {"last_run": None, "last_result": None, "last_message": None, "calendar_result": None} +_status = { + "last_run": None, + "last_result": None, + "last_message": None, + "calendar_result": None, + "last_calendar_run": None, +} _lock = threading.Lock() @@ -83,19 +89,49 @@ def _send_email_sms(message: str, phone: str, gateway: str) -> None: log.info(f"Sent ✓ → {to_addr}") -def _push_to_home_assistant(items: list[str], date_str: str) -> bool: - """Returns True if a push was attempted and succeeded, False if skipped (not configured).""" - webhook_url = os.environ.get("HA_WEBHOOK_URL") - if not webhook_url: - return False +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") resp = requests.post(webhook_url, json={"date": date_str, "items": items}, timeout=10) resp.raise_for_status() log.info("Home Assistant push ✓") - return True + + +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 + + +def _push_calendar(items: list[str], date_str: str | None) -> str: + """Push to Home Assistant if configured. Returns a calendar_result status string.""" + if not items: + return "no items" + if not os.environ.get("HA_WEBHOOK_URL"): + return "not configured" + try: + _push_to_home_assistant(items, date_str) + return "ok" + except Exception as e: + log.error(f"Home Assistant push failed: {e}") + return f"error: {e}" def run_job() -> None: + """Full daily job: scrape, push to HA calendar, and text everyone.""" global _status settings = load_settings() @@ -108,30 +144,10 @@ def run_job() -> None: phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()] gateway = settings.get("carrier_gateway", "vtext.com") - 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} (may be a break or holiday)." - except Exception as e: - log.exception("Menu fetch failed") - items, date_str = [], None - message = f"Error fetching Mariposa lunch menu: {e}" - - if len(message) > 1400: - message = message[:1397] + "..." - + items, date_str, message = _scrape_menu() log.info(f"Message:\n{message}") - calendar_result = "n/a" - if items: - try: - calendar_result = "ok" if _push_to_home_assistant(items, date_str) else "n/a" - except Exception as e: - log.error(f"Home Assistant push failed: {e}") - calendar_result = f"error: {e}" + calendar_result = _push_calendar(items, date_str) errors = [] for phone in phones: @@ -142,10 +158,25 @@ def run_job() -> None: errors.append(str(e)) _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(), } @@ -240,6 +271,27 @@ def api_send(): return jsonify({"ok": True}) +@app.route("/api/push-calendar", methods=["POST"]) +def api_push_calendar(): + threading.Thread(target=push_calendar_only, 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}) + + +@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")), + }) + + @app.route("/api/status") def api_status(): s = load_settings() @@ -373,12 +425,31 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co - +
-
Manual Send
+
Manual Actions
+
+ + +
+ +
+ + +
+
Integrations
+
+
SMS (Gmail)
—
+
Home Assistant
—
+
+
Configured via .env on the server, not here.
@@ -392,6 +463,22 @@ input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-co
+
+ + + +

@@ -441,13 +528,27 @@ const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat', let cfg = {}; async function load() { - const [s, st] = await Promise.all([ + const [s, st, ig] = await Promise.all([ fetch('/api/settings').then(r=>r.json()), fetch('/api/status').then(r=>r.json()), + fetch('/api/integrations').then(r=>r.json()), ]); cfg = s; applySettings(s); applyStatus(st); + 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) { @@ -459,6 +560,19 @@ function fmt(iso) { 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 const row = document.getElementById('days-row'); row.innerHTML = DAYS.map(d => @@ -504,10 +618,11 @@ function applyStatus(st) { : 'Active'; const calStat = document.getElementById('s-cal-stat'); const cal = st.calendar_result; - if (cal && cal !== 'n/a') { + if (cal) { calStat.style.display = ''; - const calCls = cal === 'ok' ? 'badge-green' : 'badge-red'; - document.getElementById('s-cal').innerHTML = `${cal}`; + 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'; } @@ -520,10 +635,20 @@ function applyStatus(st) { } } +function onGatewayChange() { + const sel = document.getElementById('f-gateway'); + document.getElementById('f-gateway-custom').style.display = sel.value === '__custom__' ? '' : 'none'; +} + async function saveSchedule() { const run_time = document.getElementById('f-time').value; const run_days = [...document.querySelectorAll('.day-chip.on')].map(c=>c.dataset.day); - await post('/api/settings', {run_time, run_days}); + 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}); toast('Schedule saved'); load(); } @@ -575,6 +700,31 @@ async function sendNow() { 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); +} + +async function previewMenu() { + const btn = document.getElementById('preview-btn'); + const box = document.getElementById('preview-box'); + btn.disabled = true; + btn.textContent = 'Loading…'; + try { + const r = await fetch('/api/preview'); + const data = await r.json(); + box.textContent = data.message; + box.style.display = ''; + } finally { + btn.disabled = false; + btn.textContent = "Preview Today's Menu"; + } +} + async function post(url, body) { return fetch(url, {method:'POST', headers:{'Content-Type':'application/json'}, body:JSON.stringify(body)}); }