576 lines
21 KiB
Python
576 lines
21 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 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}
|
|
_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 run_job() -> None:
|
|
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")
|
|
|
|
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")
|
|
message = f"Error fetching Mariposa lunch menu: {e}"
|
|
|
|
if len(message) > 1400:
|
|
message = message[:1397] + "..."
|
|
|
|
log.info(f"Message:\n{message}")
|
|
|
|
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 = {
|
|
"last_run": datetime.now().isoformat(),
|
|
"last_result": "error: " + "; ".join(errors) if errors else "ok",
|
|
"last_message": message,
|
|
}
|
|
|
|
|
|
# ── 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():
|
|
import requests as req
|
|
r = req.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/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 · 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>
|
|
</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>
|
|
|
|
<!-- Send Now -->
|
|
<div class="card">
|
|
<div class="card-label">Manual Send</div>
|
|
<button id="send-btn" class="btn btn-primary btn-send" onclick="sendNow()">
|
|
Send Lunch Menu Now
|
|
</button>
|
|
</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>
|
|
<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] = await Promise.all([
|
|
fetch('/api/settings').then(r=>r.json()),
|
|
fetch('/api/status').then(r=>r.json()),
|
|
]);
|
|
cfg = s;
|
|
applySettings(s);
|
|
applyStatus(st);
|
|
}
|
|
|
|
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';
|
|
// 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} → ${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 msgEl = document.getElementById('s-last-msg');
|
|
if (st.last_message) {
|
|
msgEl.textContent = st.last_message;
|
|
msgEl.style.display = '';
|
|
} else {
|
|
msgEl.style.display = '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});
|
|
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 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)
|