From a37654cdc4f68fb90d085ebbdc9bcc92722c0184 Mon Sep 17 00:00:00 2001 From: jtricerolph Date: Tue, 1 Sep 2026 20:42:35 +0000 Subject: [PATCH] Extension: per-machine default tablet, skip asking unless busy MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Options page gains a 'Default tablet for this machine' picker, populated from the device list once base URL + terminal key are configured, auto-saved on change (small enough setting not to warrant its own Save button). - Popup pre-selects the default in its device dropdown but still shows the full picker — opening the popup is already a deliberate action, so staff can still override it there. - Injected buttons (row + banner) skip the dropdown entirely when a default is configured — one click pushes straight to it. Only falls back to the full picker if that specific push comes back busy (409), with a note explaining why it's asking despite a default being set. background.js's pushBooking() now returns the HTTP status alongside ok/error so this can be detected reliably rather than string-matching the error message. Co-Authored-By: Claude Sonnet 5 --- backend/extension-src/background.js | 7 ++- backend/extension-src/content-script.js | 66 ++++++++++++++++++++- backend/extension-src/options/options.html | 10 ++++ backend/extension-src/options/options.js | 69 +++++++++++++++++----- backend/extension-src/popup/popup.js | 8 ++- 5 files changed, 139 insertions(+), 21 deletions(-) diff --git a/backend/extension-src/background.js b/backend/extension-src/background.js index fc656f1..6c872fd 100644 --- a/backend/extension-src/background.js +++ b/backend/extension-src/background.js @@ -103,8 +103,11 @@ async function pushBooking(bookingId, deviceId) { body: JSON.stringify({ booking_id: bookingId, device_id: deviceId }), }); const body = await res.json().catch(() => ({})); - if (!res.ok) return { ok: false, error: body.error || `HTTP ${res.status}` }; - return { ok: true }; + // status included so callers can reliably tell "device busy" (409) apart + // from other failures — used by content-script.js's default-tablet + // fast-path to decide whether to fall back to the device picker. + if (!res.ok) return { ok: false, status: res.status, error: body.error || `HTTP ${res.status}` }; + return { ok: true, status: res.status }; } catch (err) { return { ok: false, error: err.message }; } diff --git a/backend/extension-src/content-script.js b/backend/extension-src/content-script.js index b9538a2..10fd160 100644 --- a/backend/extension-src/content-script.js +++ b/backend/extension-src/content-script.js @@ -89,14 +89,68 @@ function makeSendButton(bookingId, guestName, variant = 'block') { btn.addEventListener('click', e => { e.preventDefault(); e.stopPropagation(); - togglePanel(btn, bookingId, guestName, variant); + handleSendClick(btn, bookingId, guestName, variant); }); return btn; } +// Injected buttons skip asking which tablet entirely when a default is +// configured for this machine (see options.js) — push straight to it. Only +// falls back to the full device-picker panel if that specific push comes +// back busy (409), or if no default is configured at all — see +// project_checkin memory's extension notes, this was an explicit UX request +// ("injected buttons don't need to ask which tablet, just use/send to +// default unless busy"). The popup is deliberately different — it always +// shows the picker, just pre-selected to the default (see popup.js) — since +// opening the popup is already a more deliberate action than a one-click +// inline button. +async function handleSendClick(anchorBtn, bookingId, guestName, variant) { + const { defaultDeviceId } = await chrome.storage.local.get('defaultDeviceId'); + + if (defaultDeviceId) { + const status = showInlineStatus(anchorBtn, variant, 'Sending…'); + const result = await chrome.runtime.sendMessage({ + action: 'tcpPush', bookingId, deviceId: defaultDeviceId, + }); + if (result?.ok) { + status.textContent = 'Sent — the tablet should navigate now.'; + status.style.color = '#166534'; + return; + } + if (result?.status === 409) { + status.remove(); + // fall through to the picker below, with a note explaining why it's + // asking despite a default being configured + await togglePanel(anchorBtn, bookingId, guestName, variant, 'Default tablet is busy — choose another:'); + return; + } + status.textContent = `Failed: ${result?.error || 'unknown error'}`; + status.style.color = '#dc2626'; + return; + } + + await togglePanel(anchorBtn, bookingId, guestName, variant); +} + +// Small text-only status line for the default-tablet fast path — no +// dropdown, nothing to pick, just feedback on what happened. +function showInlineStatus(anchorBtn, variant, text) { + if (openPanel) { openPanel.remove(); openPanel = null; } + const el = document.createElement('div'); + const width = variant === 'block' ? 'width: 100%;' : ''; + el.style.cssText = ` + display: block; margin-top: 6px; ${width} font-size: 12px; color: #64748b; + font-family: -apple-system, BlinkMacSystemFont, sans-serif; + `; + el.textContent = text; + anchorBtn.insertAdjacentElement('afterend', el); + openPanel = el; + return el; +} + let openPanel = null; -async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block') { +async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block', note = '') { if (openPanel) { openPanel.remove(); openPanel = null; } const panel = document.createElement('div'); @@ -111,7 +165,7 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block') { font-family: -apple-system, BlinkMacSystemFont, sans-serif; font-size: 12px; color: #1e293b; `; - panel.textContent = 'Loading tablets…'; + panel.textContent = note ? `${note} loading…` : 'Loading tablets…'; anchorBtn.insertAdjacentElement('afterend', panel); openPanel = panel; @@ -151,6 +205,12 @@ async function togglePanel(anchorBtn, bookingId, guestName, variant = 'block') { }); panel.innerHTML = ''; + if (note) { + const noteEl = document.createElement('div'); + noteEl.textContent = note; + noteEl.style.cssText = 'margin-bottom: 6px; color: #b45309; font-weight: 600;'; + panel.appendChild(noteEl); + } panel.appendChild(select); panel.appendChild(sendBtn); } diff --git a/backend/extension-src/options/options.html b/backend/extension-src/options/options.html index 6ff48e2..b9d9f17 100644 --- a/backend/extension-src/options/options.html +++ b/backend/extension-src/options/options.html @@ -28,6 +28,16 @@

+ + diff --git a/backend/extension-src/options/options.js b/backend/extension-src/options/options.js index 1336e62..8aea603 100644 --- a/backend/extension-src/options/options.js +++ b/backend/extension-src/options/options.js @@ -1,12 +1,62 @@ const baseUrlInput = document.getElementById('baseUrl'); const apiKeyInput = document.getElementById('apiKey'); const statusEl = document.getElementById('status'); +const defaultSection = document.getElementById('defaultTabletSection'); +const defaultDeviceSelect = document.getElementById('defaultDevice'); async function load() { - const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']); + const { baseUrl, apiKey, defaultDeviceId } = await chrome.storage.local.get(['baseUrl', 'apiKey', 'defaultDeviceId']); if (baseUrl) baseUrlInput.value = baseUrl; if (apiKey) apiKeyInput.value = apiKey; // deliberately shown back here (options page, // not the popup) so the value can be corrected without regenerating the key. + if (baseUrl && apiKey) refreshDeviceList(defaultDeviceId); +} + +// Shared by Test Connection and the default-tablet picker — one fetch, two +// uses, rather than hitting the API twice for what's the same data. +async function fetchDevices() { + const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']); + if (!baseUrl || !apiKey) throw new Error('Set both fields and save first.'); + const res = await fetch(`${baseUrl}/api/staff/devices`, { + headers: { Authorization: `Bearer ${apiKey}` }, + }); + const body = await res.json().catch(() => ([])); + if (!res.ok) throw new Error(body.error || `HTTP ${res.status}`); + return body; +} + +// Populates the "default tablet for this machine" picker — see project_checkin +// memory's extension notes: the popup pre-selects this but still lets staff +// override it; the injected buttons skip asking entirely and push straight +// to it unless it's busy. Selection auto-saves on change, no separate button +// — it's a small enough setting not to warrant one. +async function refreshDeviceList(preselectId) { + try { + const devices = await fetchDevices(); + if (!devices.length) { + defaultSection.style.display = 'none'; + return; + } + defaultDeviceSelect.innerHTML = devices.map(d => + `` + ).join(''); + if (preselectId) defaultDeviceSelect.value = preselectId; + defaultSection.style.display = ''; + } catch { + // Not configured/reachable yet — leave the section hidden rather than + // showing an empty/broken picker; Test Connection surfaces the real error. + defaultSection.style.display = 'none'; + } +} + +defaultDeviceSelect.addEventListener('change', () => { + chrome.storage.local.set({ defaultDeviceId: defaultDeviceSelect.value }); +}); + +function escapeHtml(s) { + const div = document.createElement('div'); + div.textContent = s; + return div.innerHTML; } // tablet-checkin's own base URL isn't known in advance (dev today, a @@ -33,28 +83,17 @@ async function save() { await chrome.storage.local.set({ baseUrl, apiKey }); statusEl.textContent = 'Saved.'; statusEl.style.color = '#16a34a'; + refreshDeviceList(); } async function test() { - const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']); - if (!baseUrl || !apiKey) { - statusEl.textContent = 'Set both fields and save first.'; - statusEl.style.color = '#dc2626'; - return; - } statusEl.textContent = 'Testing…'; statusEl.style.color = '#64748b'; try { - const res = await fetch(`${baseUrl}/api/staff/devices`, { - headers: { Authorization: `Bearer ${apiKey}` }, - }); - if (!res.ok) { - const body = await res.json().catch(() => ({})); - throw new Error(body.error || `HTTP ${res.status}`); - } - const devices = await res.json(); + const devices = await fetchDevices(); statusEl.textContent = `Connected — found ${devices.length} tablet(s).`; statusEl.style.color = '#16a34a'; + refreshDeviceList(); } catch (err) { statusEl.textContent = `Failed: ${err.message}`; statusEl.style.color = '#dc2626'; diff --git a/backend/extension-src/popup/popup.js b/backend/extension-src/popup/popup.js index f7a1e1e..03e35d8 100644 --- a/backend/extension-src/popup/popup.js +++ b/backend/extension-src/popup/popup.js @@ -20,7 +20,7 @@ async function main() { nbStatusEl.className = `nb-status ${ok ? 'ok' : 'no'}`; }); - const { baseUrl, apiKey } = await chrome.storage.local.get(['baseUrl', 'apiKey']); + const { baseUrl, apiKey, defaultDeviceId } = await chrome.storage.local.get(['baseUrl', 'apiKey', 'defaultDeviceId']); if (!baseUrl || !apiKey) { contentEl.innerHTML = `

Not set up yet — add the tablet-checkin URL and a terminal API key first.

@@ -72,6 +72,12 @@ async function main() { ${escapeHtml(d.name)}${d.status === 'busy' ? ' (busy)' : ''} ` ).join(''); + // Pre-select this machine's configured default tablet — still just a + // starting point, staff can pick a different one from the dropdown. + // Setting .value to a disabled (busy) option is a no-op in every browser + // that matters here, so a busy default just falls through to whatever the + // browser's own first-enabled-option default would have been anyway. + if (defaultDeviceId) deviceSelect.value = defaultDeviceId; sendBtn.disabled = false; sendBtn.addEventListener('click', async () => {