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 "