diff --git a/extension-ready/background.js b/extension-ready/background.js index 85bb064..796fa51 100644 --- a/extension-ready/background.js +++ b/extension-ready/background.js @@ -14,6 +14,20 @@ const pendingRequests = new Map(); // requestId -> AbortController +function rateLimitError(response) { + const resetHeader = response.headers.get('RateLimit-Reset') || response.headers.get('X-RateLimit-Reset'); + if (resetHeader) { + const resetSec = Number(resetHeader); + if (!isNaN(resetSec) && resetSec > 1e9) { + const resetTime = new Date(resetSec * 1000); + const hh = resetTime.getHours().toString().padStart(2, '0'); + const mm = resetTime.getMinutes().toString().padStart(2, '0'); + return `Rate limit reached — you can try again at ${hh}:${mm}.`; + } + } + return 'Rate limit reached — please try again in up to 60 minutes.'; +} + const DEFAULT_PROXY_URL = 'https://draftapply.onrender.com'; async function getProxyUrl() { @@ -104,7 +118,7 @@ async function ensureContentScriptInjected(tabId) { }); await chrome.scripting.executeScript({ target: { tabId, allFrames: true }, - files: ['page-extractor.js', 'content.js'] + files: ['stats.js', 'page-extractor.js', 'content.js'] }); } catch (err) { console.warn('Could not inject content script:', err.message); @@ -343,6 +357,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); } + 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}`); @@ -399,6 +414,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { }); } + 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}`); @@ -456,7 +472,7 @@ chrome.runtime.onMessage.addListener((message, sender, sendResponse) => { if (!mainFrameReady) { try { await chrome.scripting.insertCSS({ target: { tabId, frameIds: [0] }, files: ['content.css'] }); - await chrome.scripting.executeScript({ target: { tabId, frameIds: [0] }, files: ['page-extractor.js', 'content.js'] }); + await chrome.scripting.executeScript({ target: { tabId, frameIds: [0] }, files: ['stats.js', 'page-extractor.js', 'content.js'] }); } catch { /* restricted page */ } // Brief delay for content script to initialize after fresh injection await new Promise(r => setTimeout(r, 300)); diff --git a/extension-ready/content.js b/extension-ready/content.js index 1b31221..c01cb1f 100644 --- a/extension-ready/content.js +++ b/extension-ready/content.js @@ -379,6 +379,7 @@ class DraftApplyExtension { this.setNativeValue(target, message.answer); this.dispatchInputEvents(target, message.answer); this.showNotification('Answer inserted!'); + globalThis.DraftApplyStats?.track?.('answersInserted')?.catch?.(() => {}); } catch (e) { console.warn('[DraftApply] Insert from parent failed:', e); } @@ -1276,6 +1277,7 @@ class DraftApplyExtension { this.currentField = target; this.hideModal(); this.showNotification('Answer inserted!'); + globalThis.DraftApplyStats?.track?.('answersInserted')?.catch?.(() => {}); } catch (e) { console.warn('[DraftApply] Insert failed:', e); this.showNotification('Could not insert into that field. Try clicking the field and typing once, then Insert again.', 'error'); diff --git a/extension-ready/cv-export.html b/extension-ready/cv-export.html index 610b717..bacbb74 100644 --- a/extension-ready/cv-export.html +++ b/extension-ready/cv-export.html @@ -198,6 +198,9 @@ Chrome only adds those when no @page margin is set via CSS. */ @page { margin: 0; } + /* Keep each experience/education entry together — no mid-entry page breaks */ + .cv-entry { break-inside: avoid; } + @media print { body { background: white; } #toolbar { display: none !important; } @@ -207,6 +210,13 @@ padding: 20mm 18mm; min-height: auto; } + .cv-entry { break-inside: avoid; } + .cv-entry-row { break-after: avoid; } + .cv-job-title { break-before: avoid; break-after: avoid; } + .cv-role-focus { break-before: avoid; break-after: avoid; } + .cv-bullets { break-before: avoid; } + .cv-bullets li { break-inside: avoid; } + .cv-section-header { break-after: avoid; } } @@ -215,6 +225,7 @@
DraftApply — Harvard CV +
diff --git a/extension-ready/cv-export.js b/extension-ready/cv-export.js index f6368b6..262cea1 100644 --- a/extension-ready/cv-export.js +++ b/extension-ready/cv-export.js @@ -1,5 +1,11 @@ (async () => { document.getElementById('print-btn')?.addEventListener('click', () => window.print()); + document.getElementById('word-btn')?.addEventListener('click', () => { + const content = document.getElementById('cv-content'); + const title = document.title || 'Tailored CV'; + if (!content?.innerHTML?.trim()) return; + downloadWordDocument(content.innerHTML, title); + }); document.getElementById('close-btn')?.addEventListener('click', async () => { try { const tab = await chrome.tabs.getCurrent(); @@ -133,6 +139,148 @@ function contactLink(label, url) { return `${esc(label)}`; } +function safeDownloadName(title) { + const clean = String(title || 'Tailored CV') + .replace(/\s+CV\s*$/i, ' CV') + .replace(/[\\/:*?"<>|]+/g, '') + .replace(/\s+/g, ' ') + .trim(); + return `${clean || 'Tailored CV'}.doc`; +} + +function buildWordDocument(cvHtml, title = 'Tailored CV') { + const cleanTitle = esc(String(title || 'Tailored CV')); + return ` + + + + ${cleanTitle} + + + +${cvHtml} +`; +} + +function downloadWordDocument(cvHtml, title = 'Tailored CV') { + const blob = new Blob(['\ufeff', buildWordDocument(cvHtml, title)], { type: 'application/msword' }); + const url = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = safeDownloadName(title); + document.body.appendChild(link); + link.click(); + link.remove(); + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + function splitSkillLine(line) { const text = String(line || '') .replace(/\)\s*(?=[A-Z][A-Za-z/& ]{2,36}:)/g, ') ') @@ -201,12 +349,21 @@ function formatCvToHtml(rawText) { let pendingCompany = null; // True immediately after emitting an entry row, so the next short line → cv-job-title let afterEntryRow = false; + let openEntry = false; const closeList = () => { if (listOpen) { html += ''; listOpen = false; } }; + const closeEntry = () => { + if (openEntry) { html += ''; openEntry = false; } + }; + const emitEntryRow = (company, dates) => { + closeList(); + closeEntry(); + html += '
'; + openEntry = true; html += '
'; html += `${esc(company)}`; if (dates) html += ``; @@ -258,6 +415,7 @@ function formatCvToHtml(rawText) { if (isSectionHeader(line)) { closeList(); flushPendingCompany(null); + closeEntry(); afterEntryRow = false; if (inHeader) { html += '
'; @@ -342,7 +500,7 @@ function formatCvToHtml(rawText) { } // Line immediately after an entry row → job title (italic) - if (afterEntryRow && line.length < 70 && !isContactLine(line) && !isDateLine(line)) { + if (afterEntryRow && line.length < 120 && !isContactLine(line) && !isDateLine(line)) { const title = stripJobTitleLabel(line); if (title) html += `

${esc(title)}

`; afterEntryRow = false; @@ -356,7 +514,7 @@ function formatCvToHtml(rawText) { } // Pending company + next short non-date line → flush company (no dates), emit as job title - if (pendingCompany !== null && line.length < 70 && !isContactLine(line) && !isDateLine(line)) { + if (pendingCompany !== null && line.length < 120 && !isContactLine(line) && !isDateLine(line)) { flushPendingCompany(null); afterEntryRow = false; const title = stripJobTitleLabel(line); @@ -393,5 +551,6 @@ function formatCvToHtml(rawText) { closeList(); flushPendingCompany(null); + closeEntry(); return html; } diff --git a/extension-ready/manifest.json b/extension-ready/manifest.json index 00307c5..43f9318 100644 --- a/extension-ready/manifest.json +++ b/extension-ready/manifest.json @@ -51,7 +51,7 @@ "https://*.glassdoor.com/*", "https://*.glassdoor.co.uk/*" ], - "js": ["page-extractor.js", "content.js"], + "js": ["stats.js", "page-extractor.js", "content.js"], "css": ["content.css"], "run_at": "document_idle", "all_frames": true diff --git a/extension-ready/page-extractor.js b/extension-ready/page-extractor.js index 491bcf7..310a8bb 100644 --- a/extension-ready/page-extractor.js +++ b/extension-ready/page-extractor.js @@ -435,15 +435,22 @@ class PageExtractor { extractCleanPageText() { if (!document.body) return ''; - const clone = document.body.cloneNode(true); - const removeSelectors = [ - 'script', 'style', 'nav', 'footer', 'header', - 'aside', '.sidebar', '.navigation', '.menu', - '.cookie', '.popup', '.modal', '.ad', '.advertisement' - ]; - - for (const selector of removeSelectors) { - clone.querySelectorAll(selector).forEach(el => el.remove()); + let bodyText = ''; + try { + const clone = document.body.cloneNode(true); + const removeSelectors = [ + 'script', 'style', 'nav', 'footer', 'header', + 'aside', '.sidebar', '.navigation', '.menu', + '.cookie', '.popup', '.modal', '.ad', '.advertisement' + ]; + for (const selector of removeSelectors) { + clone.querySelectorAll(selector).forEach(el => el.remove()); + } + bodyText = clone.textContent || ''; + } catch { + // cloneNode can throw on pages with date/number inputs whose value attribute + // is "undefined" or out of range (e.g. Ashby application forms) + bodyText = document.body.innerText || document.body.textContent || ''; } const shadowText = this.openRoots() @@ -451,7 +458,7 @@ class PageExtractor { .map(root => root.textContent || '') .join('\n'); - return this.cleanText(`${clone.textContent || ''}\n${shadowText}`); + return this.cleanText(`${bodyText}\n${shadowText}`); } /** diff --git a/extension-ready/popup.html b/extension-ready/popup.html index 165c0ed..ee1a67b 100644 --- a/extension-ready/popup.html +++ b/extension-ready/popup.html @@ -308,6 +308,95 @@ background: #fafafa; } + /* ── Productivity stats ── */ + .stats-card { + background: white; + border: 1.5px solid #e2e8f0; + border-radius: 9px; + margin-top: 10px; + overflow: hidden; + } + + .stats-toggle { + width: 100%; + display: flex; + align-items: center; + gap: 8px; + padding: 9px 11px; + background: transparent; + border: 0; + font-family: inherit; + text-align: left; + cursor: pointer; + color: inherit; + } + + .stats-title { + font-size: 10.5px; + font-weight: 700; + color: #94a3b8; + text-transform: uppercase; + letter-spacing: 0.45px; + flex: 0 0 auto; + } + + .stats-summary { + min-width: 0; + flex: 1; + color: #475569; + font-size: 11.5px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + } + + .stats-chevron { + color: #94a3b8; + font-size: 13px; + line-height: 1; + transition: transform 0.2s; + } + + .stats-card.expanded .stats-chevron { transform: rotate(180deg); } + + .stats-details { + border-top: 1px solid #f1f5f9; + padding: 10px 11px 11px; + } + + .stats-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px 10px; + } + + .stats-item-value { + color: #1e293b; + font-size: 13px; + font-weight: 700; + line-height: 1.2; + } + + .stats-item-label { + color: #94a3b8; + font-size: 10.5px; + margin-top: 2px; + line-height: 1.25; + } + + .stats-reset-btn { + margin-top: 10px; + padding: 0; + background: none; + border: 0; + color: #94a3b8; + font: inherit; + font-size: 11px; + cursor: pointer; + } + + .stats-reset-btn:hover { color: #ef4444; } + /* ── Page status ── */ .page-status-card { display: flex; @@ -758,6 +847,48 @@ + +
+ + +
+
@@ -895,6 +1026,7 @@
+ diff --git a/extension-ready/popup.js b/extension-ready/popup.js index 4c55169..7809036 100644 --- a/extension-ready/popup.js +++ b/extension-ready/popup.js @@ -30,6 +30,18 @@ document.addEventListener('DOMContentLoaded', async () => { pageStatusText: document.getElementById('page-status-text'), activateBtn: document.getElementById('activate-btn'), tailorOpenBtn: document.getElementById('tailor-open-btn'), + statsCard: document.getElementById('stats-card'), + statsToggle: document.getElementById('stats-toggle'), + statsSummary: document.getElementById('stats-summary'), + statsDetails: document.getElementById('stats-details'), + statsAnswers: document.getElementById('stats-answers'), + statsExports: document.getElementById('stats-exports'), + statsTailored: document.getElementById('stats-tailored'), + statsSaved: document.getElementById('stats-saved'), + statsWeek: document.getElementById('stats-week'), + statsStreak: document.getElementById('stats-streak'), + statsTopAction: document.getElementById('stats-top-action'), + statsResetBtn: document.getElementById('stats-reset-btn'), // Tailor view mainView: document.getElementById('main-view'), tailorView: document.getElementById('tailor-view'), @@ -75,12 +87,14 @@ document.addEventListener('DOMContentLoaded', async () => { // If the value changes while a request is in flight, the response is discarded. let analyzeToken = 0; let savingDraftTimer = null; + let statsResetTimer = null; // Load saved state await loadState(); await checkProxy(); await checkPageStatus(); await restoreTailorDraft(); + await refreshStatsUI(); // ── Event listeners ────────────────────────────────────────────────────── @@ -99,6 +113,8 @@ document.addEventListener('DOMContentLoaded', async () => { elements.tailorRedoBtn.addEventListener('click', runTailorCV); elements.tailorCopyBtn.addEventListener('click', copyTailoredCV); elements.tailorPdfBtn.addEventListener('click', downloadAsPdf); + elements.statsToggle.addEventListener('click', toggleStatsCard); + elements.statsResetBtn.addEventListener('click', resetStatsWithConfirm); // Reset analysis when JD inputs change elements.tailorJd.addEventListener('input', handleTailorDraftInput); @@ -132,6 +148,48 @@ document.addEventListener('DOMContentLoaded', async () => { if (response.cvText) showCVLoaded(response.cvText); } + async function refreshStatsUI() { + const helper = window.DraftApplyStats; + if (!helper) return; + + const stats = await helper.read(); + const summary = helper.summarize(stats); + elements.statsSummary.textContent = summary.summaryText; + elements.statsAnswers.textContent = String(summary.answersInserted); + elements.statsExports.textContent = String(summary.cvExports); + elements.statsTailored.textContent = String(summary.cvsTailored); + elements.statsSaved.textContent = summary.timeSavedLabel; + elements.statsWeek.textContent = String(summary.thisWeekCount); + elements.statsStreak.textContent = `${summary.assistStreakDays} ${summary.assistStreakDays === 1 ? 'day' : 'days'}`; + elements.statsTopAction.textContent = summary.topActionLabel; + } + + function toggleStatsCard() { + const expanded = elements.statsDetails.hidden; + elements.statsDetails.hidden = !expanded; + elements.statsToggle.setAttribute('aria-expanded', String(expanded)); + elements.statsCard.classList.toggle('expanded', expanded); + } + + async function resetStatsWithConfirm() { + if (elements.statsResetBtn.dataset.confirming === 'true') { + clearTimeout(statsResetTimer); + elements.statsResetBtn.dataset.confirming = 'false'; + elements.statsResetBtn.textContent = 'Reset stats'; + await window.DraftApplyStats?.reset?.(); + await refreshStatsUI(); + return; + } + + elements.statsResetBtn.dataset.confirming = 'true'; + elements.statsResetBtn.textContent = 'Confirm reset'; + clearTimeout(statsResetTimer); + statsResetTimer = setTimeout(() => { + elements.statsResetBtn.dataset.confirming = 'false'; + elements.statsResetBtn.textContent = 'Reset stats'; + }, 4000); + } + async function checkProxy() { try { const status = await chrome.runtime.sendMessage({ type: 'CHECK_PROXY' }); @@ -499,6 +557,8 @@ document.addEventListener('DOMContentLoaded', async () => { displayTailorResults(result); await saveTailorDraft(); + await window.DraftApplyStats?.track?.('cvsTailored'); + await refreshStatsUI(); } catch (e) { showTailorMessage('Something went wrong: ' + e.message, 'error'); } finally { @@ -665,6 +725,8 @@ document.addEventListener('DOMContentLoaded', async () => { 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(); } function showTailorMessage(text, type = 'success') { diff --git a/extension-ready/stats.js b/extension-ready/stats.js new file mode 100644 index 0000000..321a9ba --- /dev/null +++ b/extension-ready/stats.js @@ -0,0 +1,173 @@ +(function () { + const STATS_KEY = 'draftapplyProductivityStats'; + const ACTIONS = ['answersInserted', 'cvExports', 'cvsTailored']; + const ACTION_LABELS = { + answersInserted: 'Insert Answer', + cvExports: 'CV Export', + cvsTailored: 'Tailor CV', + }; + + function getStorage(storage) { + if (storage) return storage; + return globalThis.chrome?.storage?.local; + } + + function emptyStats() { + return { + version: 1, + totals: { + answersInserted: 0, + cvExports: 0, + cvsTailored: 0, + }, + days: {}, + }; + } + + function normalizeStats(raw) { + const stats = emptyStats(); + const source = raw && typeof raw === 'object' ? raw : {}; + for (const action of ACTIONS) { + const value = Number(source.totals?.[action] || 0); + stats.totals[action] = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + } + + if (source.days && typeof source.days === 'object') { + for (const [day, bucket] of Object.entries(source.days)) { + if (!/^\d{4}-\d{2}-\d{2}$/.test(day) || !bucket || typeof bucket !== 'object') continue; + stats.days[day] = {}; + for (const action of ACTIONS) { + const value = Number(bucket[action] || 0); + stats.days[day][action] = Number.isFinite(value) && value > 0 ? Math.floor(value) : 0; + } + } + } + + return stats; + } + + function localDayKey(date = new Date()) { + const value = date instanceof Date ? date : new Date(date); + const year = value.getFullYear(); + const month = String(value.getMonth() + 1).padStart(2, '0'); + const day = String(value.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; + } + + function addLocalDays(date, offset) { + const next = new Date(date); + next.setHours(12, 0, 0, 0); + next.setDate(next.getDate() + offset); + return next; + } + + function bucketTotal(bucket = {}) { + return ACTIONS.reduce((sum, action) => sum + Number(bucket[action] || 0), 0); + } + + function formatCount(count, singular, plural = `${singular}s`) { + return `${count} ${count === 1 ? singular : plural}`; + } + + function formatTime(minutes) { + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins ? `${hours}h ${mins}m` : `${hours}h`; + } + + function estimatedMinutesSaved(stats) { + return (stats.totals.answersInserted * 3) + (stats.totals.cvExports * 20); + } + + function thisWeekCount(stats, today = new Date()) { + let count = 0; + for (let i = 0; i < 7; i += 1) { + const day = localDayKey(addLocalDays(today, -i)); + count += bucketTotal(stats.days[day]); + } + return count; + } + + function assistStreakDays(stats, today = new Date()) { + let streak = 0; + for (let i = 0; i < 365; i += 1) { + const day = localDayKey(addLocalDays(today, -i)); + if (bucketTotal(stats.days[day]) === 0) break; + streak += 1; + } + return streak; + } + + function topAction(stats) { + let best = ACTIONS[0]; + for (const action of ACTIONS.slice(1)) { + if (stats.totals[action] > stats.totals[best]) best = action; + } + return stats.totals[best] > 0 ? best : null; + } + + function summarize(rawStats, today = new Date()) { + const stats = normalizeStats(rawStats); + const minutes = estimatedMinutesSaved(stats); + const hasActivity = ACTIONS.some(action => stats.totals[action] > 0); + const top = topAction(stats); + + return { + answersInserted: stats.totals.answersInserted, + cvExports: stats.totals.cvExports, + cvsTailored: stats.totals.cvsTailored, + estimatedMinutesSaved: minutes, + timeSavedLabel: formatTime(minutes), + thisWeekCount: thisWeekCount(stats, today), + assistStreakDays: assistStreakDays(stats, today), + topAction: top, + topActionLabel: top ? ACTION_LABELS[top] : 'None yet', + summaryText: hasActivity + ? `${formatCount(stats.totals.answersInserted, 'answer')} inserted • ${stats.totals.cvExports} CV ${stats.totals.cvExports === 1 ? 'export' : 'exports'} • ${formatTime(minutes)} saved` + : 'No activity yet', + }; + } + + async function read(options = {}) { + const storage = getStorage(options.storage); + if (!storage) return emptyStats(); + const result = await storage.get(STATS_KEY); + return normalizeStats(result?.[STATS_KEY]); + } + + async function track(action, options = {}) { + 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()); + + 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; + } + + async function reset(options = {}) { + const storage = getStorage(options.storage); + if (!storage) return; + await storage.remove(STATS_KEY); + } + + globalThis.DraftApplyStats = { + STATS_KEY, + ACTIONS, + localDayKey, + normalizeStats, + summarize, + read, + track, + reset, + }; +})(); diff --git a/tests/cv-export.test.js b/tests/cv-export.test.js index 0679e71..1b40d04 100644 --- a/tests/cv-export.test.js +++ b/tests/cv-export.test.js @@ -24,6 +24,40 @@ function loadFormatter() { return sandbox.__formatCvToHtml; } +function loadExportHelpers() { + const code = fs.readFileSync(new URL('../extension-ready/cv-export.js', import.meta.url), 'utf8'); + const fakeEl = { + hidden: false, + innerHTML: '', + textContent: '', + addEventListener() {}, + }; + const sandbox = { + chrome: { + storage: { local: { async get() { return {}; }, async remove() {} } }, + tabs: { async getCurrent() { return null; }, async remove() {} }, + }, + document: { + title: 'Tailored CV', + body: { appendChild() {} }, + createElement() { return { click() {}, remove() {} }; }, + getElementById() { return fakeEl; }, + }, + window: { print() {}, close() {} }, + Blob, + URL, + console, + }; + + vm.runInNewContext(`${code} +globalThis.__buildWordDocument = buildWordDocument; +globalThis.__safeDownloadName = safeDownloadName;`, sandbox); + return { + buildWordDocument: sandbox.__buildWordDocument, + safeDownloadName: sandbox.__safeDownloadName, + }; +} + describe('cv-export formatter', () => { it('turns a LinkedIn label into a link and suppresses the duplicate raw URL', () => { const formatCvToHtml = loadFormatter(); @@ -154,4 +188,16 @@ TechCorp`); expect(html).not.toMatch(/Bachelor.*related field/i); expect(html).not.toMatch(/highly preferred/i); }); + + it('builds an editable Word-compatible document from the rendered CV HTML', () => { + const { buildWordDocument, safeDownloadName } = loadExportHelpers(); + const doc = buildWordDocument('

Jane Doe

Cloud engineer

', 'Jane Doe CV'); + + expect(doc).toMatch(/xmlns:w="urn:schemas-microsoft-com:office:word"/); + expect(doc).toContain(''); + expect(doc).toContain('.cv-name'); + expect(doc).toContain('Jane Doe'); + expect(doc).toContain('Cloud engineer'); + expect(safeDownloadName('Jane / Doe: CV')).toBe('Jane Doe CV.doc'); + }); }); diff --git a/tests/popup-stats-ui.test.js b/tests/popup-stats-ui.test.js new file mode 100644 index 0000000..d487947 --- /dev/null +++ b/tests/popup-stats-ui.test.js @@ -0,0 +1,33 @@ +import fs from 'node:fs'; +import { describe, expect, it } from 'vitest'; + +const popupHtml = fs.readFileSync(new URL('../extension-ready/popup.html', import.meta.url), 'utf8'); +const popupJs = fs.readFileSync(new URL('../extension-ready/popup.js', import.meta.url), 'utf8'); + +describe('popup productivity stats UI', () => { + it('renders the empty collapsed state', () => { + expect(popupHtml).toContain('id="stats-summary">No activity yet'); + }); + + it('keeps the detailed stats and reset affordance hidden behind expansion', () => { + expect(popupHtml).toContain('id="stats-details" hidden'); + expect(popupHtml).toContain('id="stats-reset-btn"'); + expect(popupHtml).toContain('Reset stats'); + }); + + it('includes the selected expanded stats', () => { + expect(popupHtml).toContain('Answers inserted'); + expect(popupHtml).toContain('CV exports'); + expect(popupHtml).toContain('CVs tailored'); + expect(popupHtml).toContain('Time saved'); + expect(popupHtml).toContain('This week'); + expect(popupHtml).toContain('Top action'); + expect(popupHtml).toContain('Assist streak'); + }); + + it('uses the helper summary text for the collapsed one-line card', () => { + expect(popupJs).toContain('elements.statsSummary.textContent = summary.summaryText'); + expect(popupJs).toContain("window.DraftApplyStats?.track?.('cvExports')"); + expect(popupJs).toContain("window.DraftApplyStats?.track?.('cvsTailored')"); + }); +}); diff --git a/tests/stats.test.js b/tests/stats.test.js new file mode 100644 index 0000000..ad88ad6 --- /dev/null +++ b/tests/stats.test.js @@ -0,0 +1,134 @@ +import fs from 'node:fs'; +import vm from 'node:vm'; +import { describe, expect, it } from 'vitest'; + +function loadStatsHelper() { + const code = fs.readFileSync(new URL('../extension-ready/stats.js', import.meta.url), 'utf8'); + const sandbox = {}; + vm.runInNewContext(code, sandbox); + return sandbox.DraftApplyStats; +} + +function fakeStorage(initial = {}) { + const data = { ...initial }; + return { + data, + async get(key) { + if (typeof key === 'string') return { [key]: data[key] }; + if (Array.isArray(key)) { + return key.reduce((result, item) => { + result[item] = data[item]; + return result; + }, {}); + } + return { ...data }; + }, + async set(values) { + Object.assign(data, values); + }, + async remove(key) { + for (const item of Array.isArray(key) ? key : [key]) delete data[item]; + }, + }; +} + +describe('productivity stats helper', () => { + it('formats the collapsed summary with answers, CV exports, and time saved', () => { + const stats = loadStatsHelper(); + const summary = stats.summarize({ + totals: { answersInserted: 12, cvExports: 2, cvsTailored: 4 }, + days: {}, + }); + + expect(summary.summaryText).toBe('12 answers inserted • 2 CV exports • 1h 16m saved'); + }); + + it('shows the empty state before any local activity', () => { + const stats = loadStatsHelper(); + + expect(stats.summarize().summaryText).toBe('No activity yet'); + }); + + it('calculates time saved from inserted answers and CV exports only', () => { + const stats = loadStatsHelper(); + const summary = stats.summarize({ + totals: { answersInserted: 3, cvExports: 2, cvsTailored: 99 }, + days: {}, + }); + + expect(summary.estimatedMinutesSaved).toBe(49); + expect(summary.timeSavedLabel).toBe('49m'); + }); + + it('counts this week as the last 7 local calendar days', () => { + const stats = loadStatsHelper(); + const today = new Date(2026, 4, 4, 12); + const summary = stats.summarize({ + totals: { answersInserted: 20, cvExports: 20, cvsTailored: 20 }, + days: { + '2026-05-04': { answersInserted: 2 }, + '2026-05-03': { cvExports: 1 }, + '2026-04-28': { cvsTailored: 3 }, + '2026-04-27': { answersInserted: 50 }, + }, + }, today); + + expect(summary.thisWeekCount).toBe(6); + }); + + it('counts the assist streak from consecutive active local days', () => { + const stats = loadStatsHelper(); + const today = new Date(2026, 4, 4, 12); + const summary = stats.summarize({ + totals: { answersInserted: 6, cvExports: 0, cvsTailored: 0 }, + days: { + '2026-05-04': { answersInserted: 1 }, + '2026-05-03': { answersInserted: 1 }, + '2026-05-02': { answersInserted: 1 }, + '2026-04-30': { answersInserted: 3 }, + }, + }, today); + + expect(summary.assistStreakDays).toBe(3); + }); + + it('chooses a deterministic top action when totals tie', () => { + const stats = loadStatsHelper(); + const summary = stats.summarize({ + totals: { answersInserted: 4, cvExports: 4, cvsTailored: 4 }, + days: {}, + }); + + expect(summary.topAction).toBe('answersInserted'); + expect(summary.topActionLabel).toBe('Insert Answer'); + }); + + it('reset clears only the stats storage key', async () => { + const stats = loadStatsHelper(); + const storage = fakeStorage({ + [stats.STATS_KEY]: { totals: { answersInserted: 1 } }, + cvText: 'keep me', + tailorCvDraft: 'also keep me', + }); + + await stats.reset({ storage }); + + expect(storage.data[stats.STATS_KEY]).toBeUndefined(); + expect(storage.data.cvText).toBe('keep me'); + expect(storage.data.tailorCvDraft).toBe('also keep me'); + }); + + it('tracks actions into local daily buckets', async () => { + const stats = loadStatsHelper(); + const storage = fakeStorage(); + + await stats.track('answersInserted', { + storage, + date: new Date(2026, 4, 4, 9), + }); + + const stored = storage.data[stats.STATS_KEY]; + expect(stored.totals.answersInserted).toBe(1); + expect(stored.days['2026-05-04'].answersInserted).toBe(1); + }); +});