diff --git a/src/server/generate_form.py b/src/server/generate_form.py
index b475992..7d9679e 100644
--- a/src/server/generate_form.py
+++ b/src/server/generate_form.py
@@ -97,9 +97,9 @@ def generate_form(
return template.render(
config=form_config,
config_json=_json_for_script(form_config),
- css=(TEMPLATES_DIR / "form.css").read_text(),
- form_js=(TEMPLATES_DIR / "form.js").read_text(),
- admin_js=(TEMPLATES_DIR / "admin.js").read_text(),
+ css=(TEMPLATES_DIR / "form.css").read_text(encoding="utf-8"),
+ form_js=(TEMPLATES_DIR / "form.js").read_text(encoding="utf-8"),
+ admin_js=(TEMPLATES_DIR / "admin.js").read_text(encoding="utf-8"),
week=week,
scraped_at=scraped_at,
deadline=deadline,
diff --git a/src/server/templates/admin.js b/src/server/templates/admin.js
index 53fcfe3..cf89ae6 100644
--- a/src/server/templates/admin.js
+++ b/src/server/templates/admin.js
@@ -4,11 +4,13 @@ const ADMIN_URL = window.ADMIN_URL;
const ADMIN_PDF_URL = window.ADMIN_PDF_URL;
const MEALS = window.MEALS;
const WEEK = window.WEEK;
+const ADMIN_REQUEST_TIMEOUT_MS = 10000;
let currentAdminWeek = '';
let editingOrder = null;
let editQuantities = {};
let lastAdminData = null;
+let latestAdminRequest = 0;
function adminHeaders() {
const h = {};
@@ -17,8 +19,22 @@ function adminHeaders() {
return h;
}
+function adminFetch(url, options = {}) {
+ const controller = new AbortController();
+ const timeout = setTimeout(() => controller.abort(), ADMIN_REQUEST_TIMEOUT_MS);
+ return fetch(url, { ...options, signal: controller.signal })
+ .finally(() => clearTimeout(timeout));
+}
+
+function setAdminActionDisabled(action, disabled) {
+ document.querySelectorAll('[data-admin-action="' + action + '"]').forEach(btn => {
+ btn.disabled = disabled;
+ });
+}
+
function loadWeekOrders(week) {
if (!week) return;
+ const requestId = ++latestAdminRequest;
currentAdminWeek = week;
editingOrder = null;
editQuantities = {};
@@ -27,14 +43,20 @@ function loadWeekOrders(week) {
document.getElementById('admin-empty').classList.add('is-hidden');
document.getElementById('admin-stats').textContent = '';
- fetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
+ adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
- .then(data => renderAdminTable(data))
+ .then(data => {
+ if (requestId === latestAdminRequest && week === currentAdminWeek) {
+ renderAdminTable(data);
+ }
+ })
.catch(() => {
- tbody.innerHTML = '
| Failed to load orders. |
';
+ if (requestId === latestAdminRequest && week === currentAdminWeek) {
+ tbody.innerHTML = '| Failed to load orders. |
';
+ }
});
}
@@ -245,7 +267,7 @@ function renderAdminTable(data) {
const t1 = document.createElement('td');
t1.textContent = 'Total';
const t2 = document.createElement('td');
- t2.textContent = mealCount + ' meals';
+ t2.textContent = mealCount + (mealCount === 1 ? ' meal' : ' meals');
const t3 = document.createElement('td');
t3.className = 'total-cell';
t3.textContent = '$' + data.grand_total.toFixed(2);
@@ -289,7 +311,7 @@ function downloadSummaryPdf() {
const week = currentAdminWeek || WEEK;
const btn = document.getElementById('admin-pdf-btn');
if (btn) btn.disabled = true;
- fetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
+ adminFetch(ADMIN_PDF_URL + '?week=' + encodeURIComponent(week), { headers: adminHeaders() })
.then(r => r.json().then(data => ({ ok: r.ok, status: r.status, data })))
.then(({ ok, status, data }) => {
if (ok && data.url) {
@@ -310,12 +332,15 @@ function downloadSummaryPdf() {
}
function adminStartEdit(email) {
- fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek), { headers: adminHeaders() })
+ const requestedWeek = currentAdminWeek;
+ setAdminActionDisabled('start-edit', true);
+ adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(requestedWeek), { headers: adminHeaders() })
.then(r => {
if (!r.ok) throw new Error('HTTP ' + r.status);
return r.json();
})
.then(data => {
+ if (currentAdminWeek !== requestedWeek) return;
lastAdminData = data;
const order = data.orders.find(o => o.employee_email === email);
if (!order) return;
@@ -324,7 +349,8 @@ function adminStartEdit(email) {
order.items.forEach(i => { editQuantities[i.name] = i.quantity; });
renderAdminTable(data);
})
- .catch(() => alert('Failed to load this order for editing.'));
+ .catch(() => alert('Failed to load this order for editing.'))
+ .finally(() => setAdminActionDisabled('start-edit', false));
}
function adminCancelEdit() {
@@ -355,26 +381,30 @@ function adminSaveEdit(email) {
}
return;
}
- fetch(ADMIN_URL, {
+ setAdminActionDisabled('save-edit', true);
+ adminFetch(ADMIN_URL, {
method: 'PUT',
headers: { ...adminHeaders(), 'Content-Type': 'application/json' },
body: JSON.stringify({ week: currentAdminWeek, email, items }),
})
.then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); })
.then(() => { editingOrder = null; editQuantities = {}; loadWeekOrders(currentAdminWeek); })
- .catch(e => alert('Failed to update: ' + e.message));
+ .catch(e => alert('Failed to update: ' + e.message))
+ .finally(() => setAdminActionDisabled('save-edit', false));
}
function adminDeleteOrder(email, name) {
const label = name ? name + ' (' + email + ')' : email;
if (!confirm('Delete order for ' + label + '?')) return;
- fetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
+ setAdminActionDisabled('delete', true);
+ adminFetch(ADMIN_URL + '?week=' + encodeURIComponent(currentAdminWeek) + '&email=' + encodeURIComponent(email), {
method: 'DELETE',
headers: adminHeaders(),
})
.then(r => { if (!r.ok) return r.json().then(d => { throw new Error(d.error); }); return r.json(); })
.then(() => loadWeekOrders(currentAdminWeek))
- .catch(e => alert('Failed to delete: ' + e.message));
+ .catch(e => alert('Failed to delete: ' + e.message))
+ .finally(() => setAdminActionDisabled('delete', false));
}
function handleAdminPanelClick(e) {
diff --git a/src/server/templates/form.css b/src/server/templates/form.css
index 550a806..e4dc97f 100644
--- a/src/server/templates/form.css
+++ b/src/server/templates/form.css
@@ -6,6 +6,7 @@
--radius-card: 12px;
--radius-control: 8px;
--shadow-card: 0 1px 3px rgba(0,0,0,0.08);
+ --sticky-footer-height: 80px;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
@@ -14,7 +15,7 @@ body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f5f7;
color: #1d1d1f;
- padding-bottom: calc(80px + env(safe-area-inset-bottom, 0px));
+ padding-bottom: calc(var(--sticky-footer-height) + env(safe-area-inset-bottom, 0px));
}
.is-hidden { display: none !important; }
@@ -194,6 +195,7 @@ header p { font-size: 0.85rem; opacity: 0.8; }
background: #fff;
border-top: 1px solid #e5e7eb;
padding: 12px 16px calc(12px + env(safe-area-inset-bottom, 0px));
+ min-height: calc(var(--sticky-footer-height) + env(safe-area-inset-bottom, 0px));
box-shadow: 0 -2px 8px rgba(0,0,0,0.1);
z-index: 100;
}
diff --git a/src/server/templates/form.html.j2 b/src/server/templates/form.html.j2
index 36d94c1..84f3ee1 100644
--- a/src/server/templates/form.html.j2
+++ b/src/server/templates/form.html.j2
@@ -70,7 +70,7 @@
{% endif %}
-
+
diff --git a/src/server/templates/form.js b/src/server/templates/form.js
index c3f175f..7124fcb 100644
--- a/src/server/templates/form.js
+++ b/src/server/templates/form.js
@@ -29,6 +29,8 @@ let reopenAtMs = null;
let countdownTimer = null;
let mealsListDelegationBound = false;
+const GOOGLE_AUTH_MAX_ATTEMPTS = 200;
+
window.googleCredential = null;
function employeePrice(retail) {
@@ -52,11 +54,17 @@ function buildItems() {
}));
}
-function waitForGoogleAuth() {
+function waitForGoogleAuth(attempt = 0) {
if (typeof google !== 'undefined' && google.accounts && google.accounts.id) {
initGoogleAuth();
+ } else if (attempt < GOOGLE_AUTH_MAX_ATTEMPTS) {
+ setTimeout(() => waitForGoogleAuth(attempt + 1), 50);
} else {
- setTimeout(waitForGoogleAuth, 50);
+ const signIn = document.getElementById('g-signin-btn');
+ if (signIn) {
+ signIn.textContent = 'Google sign-in failed to load. Refresh the page to try again.';
+ }
+ console.error('Google Identity Services failed to load.');
}
}
@@ -253,10 +261,14 @@ function updateMealCardQty(index) {
if (qtyInput && qtyInput !== document.activeElement) qtyInput.value = qty;
}
-function setQty(index, value) {
+function setQtyState(index, value) {
const next = Math.max(0, parseInt(value, 10) || 0);
if (next === 0) delete quantities[index];
else quantities[index] = next;
+}
+
+function setQty(index, value) {
+ setQtyState(index, value);
updateMealCardQty(index);
updateTotal();
}
@@ -285,7 +297,8 @@ function bindMealsListDelegation() {
const card = input.closest('[data-meal-index]');
if (!card) return;
const index = parseInt(card.dataset.mealIndex, 10);
- setQty(index, input.value);
+ setQtyState(index, input.value);
+ updateTotal();
});
list.addEventListener('focusout', (e) => {
const input = e.target.closest('.qty');
diff --git a/tests/test_generate_form.py b/tests/test_generate_form.py
index 9a705b8..ddafd78 100644
--- a/tests/test_generate_form.py
+++ b/tests/test_generate_form.py
@@ -82,6 +82,35 @@ def _google_credential() -> str:
return f"e30.{encoded}.signature"
+def _mock_form_routes(page):
+ page.route(
+ "**/api/form-status/**",
+ lambda route: route.fulfill(
+ status=200,
+ content_type="application/json",
+ body='{"status":"open"}',
+ ),
+ )
+ page.route(
+ "**/api/roster**",
+ lambda route: route.fulfill(
+ status=200,
+ content_type="application/json",
+ body=json.dumps(
+ {"employees": [{"name": "Ada Lovelace", "email": "ada@example.com"}]}
+ ),
+ ),
+ )
+ page.route(
+ "**/api/admin/orders",
+ lambda route: route.fulfill(
+ status=403,
+ content_type="application/json",
+ body='{"error":"Forbidden"}',
+ ),
+ )
+
+
class TestGenerateFormStructural:
def test_local_and_google_render(self):
local = _render(google=False)
@@ -140,6 +169,26 @@ class TestGenerateFormStructural:
local = _render(google=False)
assert "