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

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)