lunch-notifier/app.py
Barely Removable 9c1112f28e
All checks were successful
Deploy / deploy (push) Successful in 39s
Add carrier gateway field, calendar-only push, and menu preview to UI
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 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01KsJqAZ82Veyf5oihBr71S5
2026-09-03 07:18:12 -07:00

758 lines
28 KiB
Python

"""
Lunch Notifier — web UI + scheduler combined.
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 pathlib import Path
import requests
import schedule
from flask import Flask, jsonify, request
from scraper import get_lunch_menu
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger(__name__)
app = Flask(__name__)
SETTINGS_FILE = Path(os.environ.get("SETTINGS_FILE", "/data/settings.json"))
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": [],
}
_status = {
"last_run": None,
"last_result": None,
"last_message": None,
"calendar_result": None,
"last_calendar_run": None,
}
_lock = threading.Lock()
# ── Settings ──────────────────────────────────────────────────────────────────
def load_settings() -> dict:
if SETTINGS_FILE.exists():
try:
with open(SETTINGS_FILE) as f:
return json.load(f)
except Exception:
pass
return dict(_DEFAULTS)
def save_settings(s: dict) -> None:
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(SETTINGS_FILE, "w") as f:
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}")
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 ✓")
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()
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})"}
return
phones = [p.strip() for p in settings.get("phone_numbers", []) if p.strip()]
gateway = settings.get("carrier_gateway", "vtext.com")
items, date_str, message = _scrape_menu()
log.info(f"Message:\n{message}")
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))
_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(),
}
# ── Scheduler ─────────────────────────────────────────────────────────────────
DAY_MAP_FNS = {
"mon": lambda: schedule.every().monday,
"tue": lambda: schedule.every().tuesday,
"wed": lambda: schedule.every().wednesday,
"thu": lambda: schedule.every().thursday,
"fri": lambda: schedule.every().friday,
"sat": lambda: schedule.every().saturday,
"sun": lambda: schedule.every().sunday,
}
def apply_schedule(settings: dict) -> None:
with _lock:
schedule.clear()
run_time = settings.get("run_time", "07:00")
for day in settings.get("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}")
def _schedule_loop() -> None:
while True:
with _lock:
schedule.run_pending()
time.sleep(30)
# ── Flask API ─────────────────────────────────────────────────────────────────
MASCOT_URL = "https://resources.finalsite.net/images/f_png,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png"
@app.route("/")
def index():
return HTML, 200, {"Content-Type": "text/html"}
@app.route("/icon.png")
def icon():
r = requests.get(MASCOT_URL, timeout=10)
return r.content, 200, {"Content-Type": "image/png", "Cache-Control": "public, max-age=86400"}
@app.route("/api/settings", methods=["GET"])
def api_get_settings():
return jsonify(load_settings())
@app.route("/api/settings", methods=["POST"])
def api_post_settings():
data = request.get_json(force=True)
s = load_settings()
for key in ("phone_numbers", "carrier_gateway", "run_time", "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()
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()
with _lock:
next_runs = sorted([j.next_run for j in schedule.get_jobs() if j.next_run])
return jsonify({
**_status,
"next_run": next_runs[0].isoformat() if next_runs else None,
"on_vacation": is_on_vacation(s),
})
# ── Embedded UI ───────────────────────────────────────────────────────────────
HTML = """<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>🍱 Lunch Notifier</title>
<link rel="icon" href="https://resources.finalsite.net/images/f_auto,q_auto/v1708515016/kyreneorg/caewi1dctc6afx1d8nq1/Logo_Mariposa-Mascot.png">
<style>
:root {
--bg: #0d1117;
--card: #161b22;
--primary: #14b8a6;
--primary-hover: #0d9488;
--danger: #f87171;
--success: #4ade80;
--text: #e6edf3;
--muted: #8b949e;
--border: #30363d;
--radius: 12px;
--shadow: 0 1px 3px rgba(0,0,0,.4), 0 1px 2px rgba(0,0,0,.3);
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: var(--bg); color: var(--text); min-height: 100vh; }
.container { max-width: 620px; margin: 0 auto; padding: 24px 16px 48px; }
header { margin-bottom: 28px; }
header h1 { font-size: 1.5rem; font-weight: 700; }
header p { color: var(--muted); font-size: .875rem; margin-top: 2px; }
.card { background: var(--card); border-radius: var(--radius); padding: 20px;
margin-bottom: 16px; box-shadow: var(--shadow); border: 1px solid var(--border); }
.card-label { font-size: .7rem; font-weight: 700; text-transform: uppercase;
letter-spacing: .06em; color: var(--muted); margin-bottom: 14px; }
/* Status grid */
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 14px; }
.stat dt { font-size: .75rem; color: var(--muted); margin-bottom: 2px; }
.stat dd { font-size: .875rem; font-weight: 500; }
/* Badges */
.badge { display: inline-block; padding: 2px 10px; border-radius: 99px; font-size: .75rem; font-weight: 600; }
.badge-green { background: #14532d; color: #4ade80; }
.badge-yellow { background: #713f12; color: #fbbf24; }
.badge-red { background: #7f1d1d; color: #fca5a5; }
.badge-blue { background: #1e3a5f; color: #7dd3fc; }
/* Buttons */
.btn { display: inline-flex; align-items: center; justify-content: center; gap: 6px;
padding: 9px 18px; border-radius: 8px; font-size: .875rem; font-weight: 600;
cursor: pointer; border: none; transition: background .15s, opacity .15s; }
.btn:disabled { opacity: .5; cursor: not-allowed; }
.btn-primary { background: var(--primary); color: #fff; }
.btn-primary:hover:not(:disabled) { background: var(--primary-hover); }
.btn-ghost { background: transparent; color: var(--muted); border: 1px solid var(--border); }
.btn-ghost:hover { background: var(--bg); }
.btn-danger { background: var(--card); color: var(--danger); border: 1px solid #7f1d1d; }
.btn-danger:hover { background: #1a0f0f; }
.btn-send { width: 100%; padding: 14px; font-size: 1rem; border-radius: 10px; }
.btn-sm { padding: 6px 12px; font-size: .75rem; }
/* Form elements */
.field { margin-bottom: 14px; }
.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] {
width: 100%; padding: 9px 12px; border: 1px solid var(--border); border-radius: 8px;
font-size: .875rem; color: var(--text); background: var(--card);
}
input:focus { outline: 2px solid var(--primary); outline-offset: -1px; border-color: transparent; }
/* Days toggle */
.days-row { display: flex; gap: 6px; flex-wrap: wrap; }
.day-chip { padding: 6px 13px; border: 1.5px solid var(--border); border-radius: 99px;
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 */
.toast { position: fixed; bottom: 24px; left: 50%; transform: translateX(-50%) translateY(8px);
background: #1f2937; color: #fff; padding: 10px 20px; border-radius: 99px;
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; }
</style>
</head>
<body>
<div class="container">
<header>
<div style="display:flex;align-items:center;gap:16px;">
<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>
</div>
</div>
</header>
<!-- Status -->
<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>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>
<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>
</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>
<!-- Schedule -->
<div class="card">
<div class="card-label">Schedule</div>
<div class="field">
<label class="field-label">Send Time</label>
<input type="time" id="f-time" style="max-width:160px">
</div>
<div class="field">
<label class="field-label">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>
<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>
<script>
const DAYS = ['mon','tue','wed','thu','fri','sat','sun'];
const DAY_LABELS = {mon:'Mon',tue:'Tue',wed:'Wed',thu:'Thu',fri:'Fri',sat:'Sat',sun:'Sun'};
let cfg = {};
async function load() {
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
? '<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);
return d.toLocaleDateString([], {month:'short',day:'numeric'}) + ' ' +
d.toLocaleTimeString([], {hour:'numeric',minute:'2-digit'});
}
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 =>
`<span class="day-chip ${(s.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-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';
}
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});
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);
}
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)});
}
let toastTimer;
function toast(msg) {
const el = document.getElementById('toast');
el.textContent = msg;
el.classList.add('show');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove('show'), 3000);
}
load();
setInterval(load, 30000);
</script>
</body>
</html>"""
# ── Entrypoint ────────────────────────────────────────────────────────────────
if __name__ == "__main__":
if not SETTINGS_FILE.exists():
log.info(f"Creating default settings at {SETTINGS_FILE}")
save_settings(_DEFAULTS)
apply_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)