From fd4ebe0132e73610f4882e6d6691378d09c139f6 Mon Sep 17 00:00:00 2001 From: mibali Date: Mon, 4 May 2026 17:57:56 +0100 Subject: [PATCH] Hardening pass for extension UX and races --- extension-ready/background.js | 69 ++++++++++++++++++++--------------- extension-ready/content.css | 14 ++++++- extension-ready/content.js | 2 +- extension-ready/popup.js | 29 +++++++++------ extension-ready/stats.js | 41 +++++++++++++-------- tests/stats.test.js | 17 +++++++++ 6 files changed, 113 insertions(+), 59 deletions(-) diff --git a/extension-ready/background.js b/extension-ready/background.js index 796fa51..3b437a0 100644 --- a/extension-ready/background.js +++ b/extension-ready/background.js @@ -385,45 +385,52 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { const proxyUrl = await getProxyUrl(); let token = await ensureInstallToken(proxyUrl); + const controller = new AbortController(); + const timeout = setTimeout(() => controller.abort(), 45000); + const keepAlive = setInterval(() => chrome.storage.local.get('_sw_keepalive'), 20000); - let response = await fetch(`${proxyUrl}/api/cv/analyze`, { - method: 'POST', - headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ + try { + const body = JSON.stringify({ cvText, jobDescription: message.jobDescription, jobTitle: message.jobTitle || '', company: message.company || '', confirmedSkills: message.confirmedSkills || [], - }), - }); + }); - if (response.status === 401) { - await clearInstallToken(); - token = await ensureInstallToken(proxyUrl); - response = await fetch(`${proxyUrl}/api/cv/analyze`, { + let response = await fetch(`${proxyUrl}/api/cv/analyze`, { method: 'POST', headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, - body: JSON.stringify({ - cvText, - jobDescription: message.jobDescription, - jobTitle: message.jobTitle || '', - company: message.company || '', - confirmedSkills: message.confirmedSkills || [], - }), + signal: controller.signal, + body, }); - } - if (response.status === 429) throw new Error(rateLimitError(response)); - if (!response.ok) { - const err = await response.json().catch(() => ({})); - throw new Error(err.error || `Error ${response.status}`); - } + if (response.status === 401) { + await clearInstallToken(); + token = await ensureInstallToken(proxyUrl); + response = await fetch(`${proxyUrl}/api/cv/analyze`, { + method: 'POST', + headers: { 'Content-Type': 'application/json', Authorization: `Bearer ${token}` }, + signal: controller.signal, + body, + }); + } + + if (response.status === 429) throw new Error(rateLimitError(response)); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.error || `Error ${response.status}`); + } - const data = await response.json(); - sendResponse({ success: true, ...data }); + const data = await response.json(); + sendResponse({ success: true, ...data }); + } finally { + clearTimeout(timeout); + clearInterval(keepAlive); + } } catch (e) { - sendResponse({ error: e.message }); + if (e?.name === 'AbortError') sendResponse({ error: 'Analysis timed out — please try again' }); + else sendResponse({ error: e.message }); } })(); return true; @@ -575,9 +582,9 @@ async function handleStreamingAPICall(payload, requestId, tabId, frameId) { const enrichedPayload = { ...payload, stream: true }; try { - const token = await ensureInstallToken(proxyUrl); + let token = await ensureInstallToken(proxyUrl); - const response = await fetch(`${proxyUrl}/api/generate`, { + const doRequest = () => fetch(`${proxyUrl}/api/generate`, { method: 'POST', headers: { 'Content-Type': 'application/json', @@ -587,10 +594,12 @@ async function handleStreamingAPICall(payload, requestId, tabId, frameId) { body: JSON.stringify(enrichedPayload) }); + let response = await doRequest(); + if (response.status === 401) { await clearInstallToken(); - await ensureInstallToken(proxyUrl); // refresh token for next attempt - throw new Error('Token expired — please try again'); + token = await ensureInstallToken(proxyUrl); + response = await doRequest(); } if (!response.ok) { diff --git a/extension-ready/content.css b/extension-ready/content.css index 98469d2..1ad8b68 100644 --- a/extension-ready/content.css +++ b/extension-ready/content.css @@ -143,18 +143,28 @@ .da-modal-close { margin-left: auto; - background: none; + appearance: none; + width: 36px; + height: 36px; + display: inline-flex; + align-items: center; + justify-content: center; + background: rgba(255, 255, 255, 0.08); border: none; + border-radius: 8px; color: white; font-size: 24px; cursor: pointer; padding: 0; line-height: 1; opacity: 0.8; - transition: opacity 0.2s; + flex-shrink: 0; + transition: opacity 0.2s, background 0.2s; + -webkit-tap-highlight-color: transparent; } .da-modal-close:hover { + background: rgba(255, 255, 255, 0.16); opacity: 1; } diff --git a/extension-ready/content.js b/extension-ready/content.js index c01cb1f..2224597 100644 --- a/extension-ready/content.js +++ b/extension-ready/content.js @@ -170,7 +170,7 @@ class DraftApplyExtension { DraftApply No context - +
diff --git a/extension-ready/popup.js b/extension-ready/popup.js index 7809036..f838112 100644 --- a/extension-ready/popup.js +++ b/extension-ready/popup.js @@ -89,13 +89,6 @@ document.addEventListener('DOMContentLoaded', async () => { let savingDraftTimer = null; let statsResetTimer = null; - // Load saved state - await loadState(); - await checkProxy(); - await checkPageStatus(); - await restoreTailorDraft(); - await refreshStatsUI(); - // ── Event listeners ────────────────────────────────────────────────────── elements.saveCvBtn.addEventListener('click', saveCV); @@ -141,6 +134,16 @@ document.addEventListener('DOMContentLoaded', async () => { if (file) processFile(file); }); + // Load saved state after binding handlers so the popup never feels dead while + // proxy/page checks are warming up. + await Promise.allSettled([ + loadState(), + checkProxy(), + checkPageStatus(), + restoreTailorDraft(), + refreshStatsUI(), + ]); + // ── CV management ───────────────────────────────────────────────────────── async function loadState() { @@ -723,10 +726,14 @@ document.addEventListener('DOMContentLoaded', async () => { async function downloadAsPdf() { const text = elements.tailorOutput.value; if (!text) return; - await chrome.storage.local.set({ tailoredCvExport: text }); - chrome.tabs.create({ url: chrome.runtime.getURL('cv-export.html') }); - await window.DraftApplyStats?.track?.('cvExports'); - await refreshStatsUI(); + try { + await chrome.storage.local.set({ tailoredCvExport: text }); + await chrome.tabs.create({ url: chrome.runtime.getURL('cv-export.html') }); + await window.DraftApplyStats?.track?.('cvExports'); + await refreshStatsUI(); + } catch (e) { + showTailorMessage('Could not open the export page. Please try again.', 'error'); + } } function showTailorMessage(text, type = 'success') { diff --git a/extension-ready/stats.js b/extension-ready/stats.js index 321a9ba..e8bcf0b 100644 --- a/extension-ready/stats.js +++ b/extension-ready/stats.js @@ -6,6 +6,7 @@ cvExports: 'CV Export', cvsTailored: 'Tailor CV', }; + let writeQueue = Promise.resolve(); function getStorage(storage) { if (storage) return storage; @@ -137,27 +138,37 @@ } async function track(action, options = {}) { - if (!ACTIONS.includes(action)) return read(options); - const storage = getStorage(options.storage); - if (!storage) return emptyStats(); + return enqueueWrite(async () => { + if (!ACTIONS.includes(action)) return read(options); + const storage = getStorage(options.storage); + if (!storage) return emptyStats(); - const amount = Math.max(1, Math.floor(Number(options.amount || 1))); - const stats = await read({ storage }); - const day = localDayKey(options.date || new Date()); + const amount = Math.max(1, Math.floor(Number(options.amount || 1))); + const stats = await read({ storage }); + const day = localDayKey(options.date || new Date()); - stats.totals[action] += amount; - stats.days[day] = stats.days[day] || {}; - for (const key of ACTIONS) stats.days[day][key] = Number(stats.days[day][key] || 0); - stats.days[day][action] += amount; + stats.totals[action] += amount; + stats.days[day] = stats.days[day] || {}; + for (const key of ACTIONS) stats.days[day][key] = Number(stats.days[day][key] || 0); + stats.days[day][action] += amount; - await storage.set({ [STATS_KEY]: stats }); - return stats; + await storage.set({ [STATS_KEY]: stats }); + return stats; + }); } async function reset(options = {}) { - const storage = getStorage(options.storage); - if (!storage) return; - await storage.remove(STATS_KEY); + return enqueueWrite(async () => { + const storage = getStorage(options.storage); + if (!storage) return; + await storage.remove(STATS_KEY); + }); + } + + function enqueueWrite(task) { + const next = writeQueue.catch(() => {}).then(task); + writeQueue = next.catch(() => {}); + return next; } globalThis.DraftApplyStats = { diff --git a/tests/stats.test.js b/tests/stats.test.js index ad88ad6..c9da704 100644 --- a/tests/stats.test.js +++ b/tests/stats.test.js @@ -131,4 +131,21 @@ describe('productivity stats helper', () => { expect(stored.totals.answersInserted).toBe(1); expect(stored.days['2026-05-04'].answersInserted).toBe(1); }); + + it('serializes same-context writes so quick repeated actions do not lose counts', async () => { + const stats = loadStatsHelper(); + const storage = fakeStorage(); + + await Promise.all([ + stats.track('answersInserted', { storage, date: new Date(2026, 4, 4, 9) }), + stats.track('answersInserted', { storage, date: new Date(2026, 4, 4, 9) }), + stats.track('cvExports', { storage, date: new Date(2026, 4, 4, 9) }), + ]); + + const stored = storage.data[stats.STATS_KEY]; + expect(stored.totals.answersInserted).toBe(2); + expect(stored.totals.cvExports).toBe(1); + expect(stored.days['2026-05-04'].answersInserted).toBe(2); + expect(stored.days['2026-05-04'].cvExports).toBe(1); + }); });