diff --git a/.github/workflows/build-extension.yml b/.github/workflows/build-extension.yml index 119ef27..1817123 100644 --- a/.github/workflows/build-extension.yml +++ b/.github/workflows/build-extension.yml @@ -33,7 +33,6 @@ jobs: - name: Check JavaScript syntax run: | - node --check background.js node --check content.js node --check popup.js @@ -55,7 +54,7 @@ jobs: - name: Create extension archive run: >- zip -X extension.zip - manifest.json content.js background.js popup.html popup.css popup.js + manifest.json content.js popup.html popup.css popup.js icon.png README.md - name: Verify archive integrity diff --git a/.github/workflows/release-extension.yml b/.github/workflows/release-extension.yml index 1fe90ba..d93682c 100644 --- a/.github/workflows/release-extension.yml +++ b/.github/workflows/release-extension.yml @@ -34,7 +34,6 @@ jobs: - name: Check JavaScript syntax run: | - node --check background.js node --check content.js node --check popup.js @@ -47,7 +46,7 @@ jobs: --format=zip \ --output="${RUNNER_TEMP}/extension-test.zip" \ HEAD -- \ - manifest.json content.js background.js popup.html popup.css popup.js icon.png + manifest.json content.js popup.html popup.css popup.js icon.png unzip -t "${RUNNER_TEMP}/extension-test.zip" release: @@ -100,7 +99,7 @@ jobs: --format=zip \ --output="${ARCHIVE_NAME}" \ HEAD -- \ - manifest.json content.js background.js popup.html popup.css popup.js icon.png + manifest.json content.js popup.html popup.css popup.js icon.png unzip -t "${ARCHIVE_NAME}" sha256sum "${ARCHIVE_NAME}" > "${ARCHIVE_NAME}.sha256" diff --git a/AGENTS.md b/AGENTS.md index bcd3ff1..47f2509 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -3,6 +3,9 @@ - Follow [CONTRIBUTING.md](CONTRIBUTING.md), including its testing and sensitive-data rules. - Never commit HAR files, credentials, or real Credit Karma financial data. - Run the documented checks before submitting changes. +- If you make changes in a fork or a local checkout, open a pull request against the upstream repository (`cbangera2/CreditKarmaExtractor`) so everyone benefits. Do not leave improvements only in a fork or on a local branch; if you cannot open the pull request yourself, tell the user the exact steps. +- Pull requests that change the popup or any other UI must include screenshots (attach them with `gh pr create --attach`). +- Pull requests are squash merged. Tell the user before merging, and only merge once the checks pass and everything has been verified. - Use a Conventional Commits prefix (`feat:`, `fix:`, `docs:`, `ci:`, `chore:`) in commit messages and pull request titles, and give pull requests **Summary** and **Validation** sections as described in CONTRIBUTING.md. ## AI transparency diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 1a7f6ec..16c7724 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -9,7 +9,6 @@ Thanks for helping improve Credit Karma Data Extractor. 3. Run the local checks: ```sh - node --check background.js node --check content.js node --check popup.js node --test @@ -47,8 +46,10 @@ If you accidentally publish a HAR containing sensitive information, remove it im - Structure the description with these sections: - **Summary**: what changed and why, plus the issue it relates to (use `Closes #N` only when it fully resolves the issue). - **Validation**: the checks you ran, and anything you did not test, such as a live export on Credit Karma. - - **Screenshots**: for popup or other UI changes. `gh pr create --attach` uploads them. + - **Screenshots**: required for any change to the popup or other UI. `gh pr create --attach` uploads them. +- If you improve the extension in a fork or on a local copy, please open a pull request upstream so everyone benefits. - Keep changes focused and avoid unrelated formatting edits. - Add or update tests when changing parsers or export formats. - Do not include real Credit Karma data in tests; use small synthetic fixtures. - Ensure CI passes before requesting review. +- Pull requests are squash merged, so the pull request title becomes the commit message on `main`. diff --git a/background.js b/background.js deleted file mode 100644 index f3b2379..0000000 --- a/background.js +++ /dev/null @@ -1,13 +0,0 @@ -chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { - if (request.action === 'captureTransactions') { - chrome.tabs.query({active: true, currentWindow: true}, (tabs) => { - chrome.scripting.executeScript({ - target: {tabId: tabs[0].id}, - function: captureTransactionsInDateRange, - args: [request.startDate, request.endDate] - }); - sendResponse({status: 'started'}); - }); - return true; // Indicates that the response is asynchronous - } -}); \ No newline at end of file diff --git a/content.js b/content.js index 94dbd95..85f2782 100644 --- a/content.js +++ b/content.js @@ -1394,6 +1394,20 @@ function buildExportFileName(key, extension, dateSuffix, customNames = {}) { : `${DEFAULT_EXPORT_BASE_NAMES[key]}_${dateSuffix}.${extension}`; } +/** + * Tell the popup (if it is still open) that an export finished or failed. + * Nothing is listening once the popup closes, so a missing receiver is ignored. + */ +function notifyExportFinished(status, message) { + try { + chrome.runtime.sendMessage({ action: 'exportFinished', status, message }, () => { + void chrome.runtime.lastError; + }); + } catch (error) { + // Extension context invalidated or no receiver; nothing to do. + } +} + function saveCSVToFile(csvData, fileName) { const blob = new Blob([csvData], { type: 'text/csv' }); const link = document.createElement('a'); @@ -1977,7 +1991,9 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { completionNotice.style.borderRadius = '5px'; completionNotice.style.zIndex = '9999'; completionNotice.style.fontSize = '14px'; - completionNotice.textContent = `Export complete: ${summaryParts.join(' and ')}.`; + const completionMessage = `Export complete: ${summaryParts.join(' and ')}.`; + completionNotice.textContent = completionMessage; + notifyExportFinished('success', completionMessage); document.body.appendChild(completionNotice); setTimeout(() => { @@ -1990,6 +2006,7 @@ chrome.runtime.onMessage.addListener((request, sender, sendResponse) => { console.error('Error during data export:', error); alert(`Error during data export: ${error.message}`); + notifyExportFinished('error', `Error during data export: ${error.message}`); }); } catch (error) { diff --git a/manifest.json b/manifest.json index ab858b5..2c7d2f8 100644 --- a/manifest.json +++ b/manifest.json @@ -25,9 +25,6 @@ ] } ], - "background": { - "service_worker": "background.js" - }, "content_security_policy": { "extension_pages": "script-src 'self'; object-src 'self'" } diff --git a/popup.css b/popup.css index dd7b583..1205e7e 100644 --- a/popup.css +++ b/popup.css @@ -372,6 +372,30 @@ input:checked+.slider:before { font: inherit; } +.status { + box-sizing: border-box; + margin: 0 0 8px; + max-height: 80px; + overflow-y: auto; + padding: 8px 10px; + border: 1px solid var(--border); + border-radius: 7px; + background: var(--surface); + color: var(--text); + font-size: 11px; + line-height: 1.4; +} + +.status[data-kind="error"] { + border-color: #dc2626; + color: #dc2626; +} + +.status[data-kind="success"] { + border-color: #16a34a; + color: #16a34a; +} + .secondary-btn { width: 100%; margin-top: 10px; diff --git a/popup.html b/popup.html index f37e903..e4f13d9 100644 --- a/popup.html +++ b/popup.html @@ -35,6 +35,8 @@

Date Range

+ + @@ -174,7 +176,8 @@

Transaction Columns

-
+
+
diff --git a/popup.js b/popup.js index 80efaf7..30a3b62 100644 --- a/popup.js +++ b/popup.js @@ -103,38 +103,71 @@ for (const id of PERSISTED_VALUE_IDS) { } // Quick Date Logic +function formatDateInput(date) { + // Format as YYYY-MM-DD using local time + const year = date.getFullYear(); + const month = String(date.getMonth() + 1).padStart(2, '0'); + const day = String(date.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +function quickRangeValues(range, now = new Date()) { + let start, end; + + if (range === 'last-30' || range === 'last-90') { + const days = range === 'last-30' ? 30 : 90; + start = new Date(now.getFullYear(), now.getMonth(), now.getDate() - days); + end = now; + } else if (range === 'ytd') { + start = new Date(now.getFullYear(), 0, 1); + end = now; + } else if (range === 'last-month') { + start = new Date(now.getFullYear(), now.getMonth() - 1, 1); + end = new Date(now.getFullYear(), now.getMonth(), 0); + } else if (range === 'last-year') { + start = new Date(now.getFullYear() - 1, 0, 1); + end = new Date(now.getFullYear() - 1, 11, 31); + } + + return start && end ? { start: formatDateInput(start), end: formatDateInput(end) } : null; +} + document.querySelectorAll('.pill-btn').forEach(btn => { btn.addEventListener('click', () => { - const range = btn.dataset.range; - const now = new Date(); - let start, end; - - if (range === 'ytd') { - start = new Date(now.getFullYear(), 0, 1); - end = now; - } else if (range === 'last-month') { - start = new Date(now.getFullYear(), now.getMonth() - 1, 1); - end = new Date(now.getFullYear(), now.getMonth(), 0); - } else if (range === 'last-year') { - start = new Date(now.getFullYear() - 1, 0, 1); - end = new Date(now.getFullYear() - 1, 11, 31); - } - - if (start && end) { - // Format as YYYY-MM-DD using local time - const formatDate = (date) => { - const year = date.getFullYear(); - const month = String(date.getMonth() + 1).padStart(2, '0'); - const day = String(date.getDate()).padStart(2, '0'); - return `${year}-${month}-${day}`; - }; - document.getElementById('start-date').value = formatDate(start); - document.getElementById('end-date').value = formatDate(end); + const values = quickRangeValues(btn.dataset.range); + if (values) { + document.getElementById('start-date').value = values.start; + document.getElementById('end-date').value = values.end; saveSettings(); } }); }); +// Inline status line (replaces alert() dialogs, which are jarring in an extension popup) +const statusLine = document.getElementById('status'); + +// The status line lives in the fixed action bar so it is always visible without scrolling. +// Keep the page padding in step with the bar so no content is hidden underneath it. +function syncActionBarPadding() { + const bar = document.getElementById('action-bar'); + if (bar && document.body.style) { + document.body.style.paddingBottom = `${bar.offsetHeight + 12}px`; + } +} + +function showStatus(message, kind = 'info') { + statusLine.textContent = message; + statusLine.dataset.kind = kind; + statusLine.hidden = false; + syncActionBarPadding(); +} + +function clearStatus() { + statusLine.textContent = ''; + statusLine.hidden = true; + syncActionBarPadding(); +} + /** * Send a message to the content script. If it doesn't answer (stale or missing * content script — e.g. the page was loaded before the extension was updated), @@ -176,6 +209,7 @@ function sendMessageWithInjection(tab, message, callback) { // Debug: dump raw API responses to a JSON file document.getElementById('debug-raw-btn').addEventListener('click', () => { + clearStatus(); const btn = document.getElementById('debug-raw-btn'); const originalText = btn.textContent; btn.textContent = 'Dumping...'; @@ -183,16 +217,16 @@ document.getElementById('debug-raw-btn').addEventListener('click', () => { chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (!tabs[0]) { - alert('No active tab found.'); + showStatus('No active tab found.', 'error'); btn.textContent = originalText; btn.disabled = false; return; } sendMessageWithInjection(tabs[0], { action: 'debugDumpRaw', maxHubPages: 5 }, (response, error) => { if (error) { - alert(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`); + showStatus(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`, 'error'); } else if (response?.status === 'error') { - alert(`Raw API dump failed: ${response.message}`); + showStatus(`Raw API dump failed: ${response.message}`, 'error'); } btn.textContent = originalText; btn.disabled = false; @@ -201,7 +235,23 @@ document.getElementById('debug-raw-btn').addEventListener('click', () => { }); // Export Logic +const exportBtn = document.getElementById('export-btn'); +const exportBtnText = exportBtn.textContent; + +function resetExportButton() { + exportBtn.textContent = exportBtnText; + exportBtn.disabled = false; +} + +// The content script reports back when an export finishes (or fails) +chrome.runtime.onMessage?.addListener(message => { + if (message?.action !== 'exportFinished') return; + showStatus(message.message, message.status === 'success' ? 'success' : 'error'); + resetExportButton(); +}); + document.getElementById('export-btn').addEventListener('click', () => { + clearStatus(); const startDate = document.getElementById('start-date').value; const endDate = document.getElementById('end-date').value; @@ -220,14 +270,14 @@ document.getElementById('export-btn').addEventListener('click', () => { }; if (!Object.values(csvTypes).some(Boolean)) { - alert('Please select at least one file to generate.'); + showStatus('Please select at least one file to generate.', 'error'); return; } const needsDateRange = Object.entries(csvTypes) .some(([type, selected]) => type !== 'wealthAccounts' && selected); if (needsDateRange && (!startDate || !endDate)) { - alert('Please select both start and end dates.'); + showStatus('Please select both start and end dates.', 'error'); return; } @@ -244,16 +294,14 @@ document.getElementById('export-btn').addEventListener('click', () => { }; // UI Feedback - const exportBtn = document.getElementById('export-btn'); - const originalText = exportBtn.textContent; exportBtn.textContent = 'Processing...'; exportBtn.disabled = true; // Send to Content Script chrome.tabs.query({ active: true, currentWindow: true }, (tabs) => { if (!tabs[0]) { - alert('No active tab found.'); - resetButton(); + showStatus('No active tab found.', 'error'); + resetExportButton(); return; } @@ -270,22 +318,18 @@ document.getElementById('export-btn').addEventListener('click', () => { // Handle error (e.g. content script not loaded) if (error) { console.error(error); - alert(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`); - resetButton(); + showStatus(`Connection error (${error}): Make sure you are on creditkarma.com and reload the page.`, 'error'); + resetExportButton(); return; } if (response && response.status === 'started') { - // Keep button disabled for a bit to prevent double clicks - setTimeout(resetButton, 3000); + // Stay disabled until the content script reports the export finished + showStatus('Exporting... keep this Credit Karma tab open.'); } else { - resetButton(); + if (response?.status === 'error') showStatus(response.message, 'error'); + resetExportButton(); } }); }); - - function resetButton() { - exportBtn.textContent = originalText; - exportBtn.disabled = false; - } }); diff --git a/tests/content.test.js b/tests/content.test.js index d35f169..eb24703 100644 --- a/tests/content.test.js +++ b/tests/content.test.js @@ -25,7 +25,8 @@ vm.runInContext(`${contentScript}\n;globalThis.testExports = { convertWealthAccountsToCSV, shouldExportCurrentWealth, buildBudgetLensBundle, - buildExportFileName + buildExportFileName, + convertToCSV };`, context); const { @@ -40,7 +41,8 @@ const { convertWealthAccountsToCSV, shouldExportCurrentWealth, buildBudgetLensBundle, - buildExportFileName + buildExportFileName, + convertToCSV } = context.testExports; function point(date, value) { @@ -421,3 +423,58 @@ test('custom file names are sanitized', () => { assert.equal(buildExportFileName('income', 'csv', 'd', { income: ' ' }), 'income_d.csv'); assert.equal(buildExportFileName('income', 'csv', 'd', { income: '...' }), 'income_d.csv'); }); + +test('transaction CSV includes the header and quotes every field', () => { + const csv = convertToCSV([{ + date: '2026-07-04', + description: 'Coffee', + amount: '4.50', + category: 'Dining', + transactionType: 'debit', + accountName: 'Checking', + accountType: 'Bank', + provider: 'Example Bank' + }]); + + assert.equal(csv.split('\n')[0], + 'Date,Description,Amount,Category,Transaction Type,Account Name,Account Type,Provider,Labels,Notes'); + assert.equal(csv.split('\n')[1], + '"07/04/2026","Coffee","4.50","Dining","debit","Checking","Bank","Example Bank","",""'); +}); + +test('transaction CSV keeps commas, quotes, and newlines inside a single quoted field', () => { + const csv = convertToCSV([{ + date: '2026-07-04', + description: 'Smith, John "JJ" Cafe\nDowntown', + amount: '1.00', + category: 'A, B', + transactionType: 'debit' + }], { date: true, description: true, amount: true, category: true }); + + assert.equal( + csv, + 'Date,Description,Amount,Category\n"07/04/2026","Smith, John ""JJ"" Cafe\nDowntown","1.00","A, B"\n' + ); +}); + +test('transaction CSV only includes the selected columns', () => { + const csv = convertToCSV([{ + date: '2026-07-04', + description: 'Coffee', + amount: '4.50', + category: 'Dining', + transactionType: 'debit' + }], { date: true, amount: true }); + + assert.equal(csv, 'Date,Amount\n"07/04/2026","4.50"\n'); +}); + +test('transaction CSV leaves missing optional fields empty and handles no transactions', () => { + const csv = convertToCSV([{ date: '2026-07-04', amount: '1', transactionType: 'credit' }]); + + assert.equal(csv.split('\n')[1], '"07/04/2026","","1","","credit","","","","",""'); + assert.equal( + convertToCSV([], { date: true, amount: true }), + 'Date,Amount\n' + ); +}); diff --git a/tests/manifest.test.js b/tests/manifest.test.js index 801963d..aa0499f 100644 --- a/tests/manifest.test.js +++ b/tests/manifest.test.js @@ -21,7 +21,6 @@ test('every local file referenced by the manifest exists', () => { const referencedFiles = [ manifest.action.default_popup, ...Object.values(manifest.action.default_icon), - manifest.background.service_worker, ...manifest.content_scripts.flatMap(contentScript => contentScript.js || []) ]; @@ -35,3 +34,7 @@ test('content scripts are limited to Credit Karma pages', () => { assert.deepEqual(contentScript.matches, ['*://www.creditkarma.com/*']); } }); + +test('manifest does not register a background service worker', () => { + assert.equal(manifest.background, undefined); +}); diff --git a/tests/popup.test.js b/tests/popup.test.js index bd9134c..abd71d4 100644 --- a/tests/popup.test.js +++ b/tests/popup.test.js @@ -8,7 +8,7 @@ const popupScript = fs.readFileSync(path.join(__dirname, '..', 'popup.js'), 'utf function createPopupContext(savedStorage = {}) { const listeners = new Map(); - const alerts = []; + let runtimeListener = null; const sentMessages = []; const checkboxIds = [ 'useApiCheckbox', @@ -46,6 +46,8 @@ function createPopupContext(savedStorage = {}) { } element('theme-toggle'); + element('status').hidden = true; + elements.get('status').dataset = {}; element('start-date'); element('end-date'); element('export-btn'); @@ -77,10 +79,12 @@ function createPopupContext(savedStorage = {}) { getItem: key => savedStorage[key] ?? null, setItem(key, value) { savedStorage[key] = value; } }, - alert: message => alerts.push(message), setTimeout: callback => callback(), chrome: { - runtime: { lastError: null }, + runtime: { + lastError: null, + onMessage: { addListener(callback) { runtimeListener = callback; } } + }, scripting: { executeScript() {} }, tabs: { query(_options, callback) { @@ -96,16 +100,23 @@ function createPopupContext(savedStorage = {}) { vm.createContext(context); vm.runInContext(popupScript, context); - return { alerts, elements, listeners, sentMessages, savedStorage }; + const status = () => { + const el = elements.get('status'); + return el.hidden ? null : { text: el.textContent, kind: el.dataset.kind }; + }; + return { + context, elements, listeners, sentMessages, savedStorage, status, + sendRuntimeMessage: message => runtimeListener(message) + }; } test('current wealth account snapshots can export without a historical date range', () => { - const { alerts, elements, listeners, sentMessages } = createPopupContext(); + const { status, elements, listeners, sentMessages } = createPopupContext(); elements.get('wealthAccountsCheckbox').checked = true; listeners.get('export-btn:click')(); - assert.deepEqual(alerts, []); + assert.notEqual(status()?.kind, 'error'); assert.equal(sentMessages.length, 1); assert.equal(sentMessages[0].csvTypes.wealthAccounts, true); assert.equal(sentMessages[0].startDate, ''); @@ -113,24 +124,25 @@ test('current wealth account snapshots can export without a historical date rang }); test('historical exports still require both dates', () => { - const { alerts, elements, listeners, sentMessages } = createPopupContext(); + const { status, elements, listeners, sentMessages } = createPopupContext(); elements.get('netWorthCheckbox').checked = true; listeners.get('export-btn:click')(); - assert.deepEqual(alerts, ['Please select both start and end dates.']); + assert.equal(status().text, 'Please select both start and end dates.'); + assert.equal(status().kind, 'error'); assert.deepEqual(sentMessages, []); }); test('BudgetLens bundle is sent as a distinct export type', () => { - const { alerts, elements, listeners, sentMessages } = createPopupContext(); + const { status, elements, listeners, sentMessages } = createPopupContext(); elements.get('budgetLensBundleCheckbox').checked = true; elements.get('start-date').value = '2026-01-01'; elements.get('end-date').value = '2026-07-30'; listeners.get('export-btn:click')(); - assert.deepEqual(alerts, []); + assert.notEqual(status()?.kind, 'error'); assert.equal(sentMessages.length, 1); assert.equal(sentMessages[0].csvTypes.budgetLensBundle, true); assert.equal(sentMessages[0].csvTypes.allTransactions, false); @@ -189,3 +201,83 @@ test('corrupt or partial saved settings fall back to the defaults', () => { assert.equal(partial.elements.get('col-date').checked, true); assert.equal(partial.elements.get('end-date').value, ''); }); + +test('selecting no export shows an inline error instead of an alert', () => { + const { elements, listeners, sentMessages, status } = createPopupContext(); + elements.get('budgetLensBundleCheckbox').checked = false; + + listeners.get('export-btn:click')(); + + assert.equal(status().text, 'Please select at least one file to generate.'); + assert.equal(status().kind, 'error'); + assert.equal(sentMessages.length, 0); +}); + +test('export button stays disabled until the content script reports completion', () => { + const { elements, listeners, status, sendRuntimeMessage } = createPopupContext(); + elements.get('wealthAccountsCheckbox').checked = true; + + listeners.get('export-btn:click')(); + + assert.equal(elements.get('export-btn').disabled, true); + assert.equal(status().kind, 'info'); + + sendRuntimeMessage({ action: 'exportFinished', status: 'success', message: 'Export complete: 3 transactions.' }); + + assert.equal(elements.get('export-btn').disabled, false); + assert.equal(status().text, 'Export complete: 3 transactions.'); + assert.equal(status().kind, 'success'); +}); + +test('export failures reported by the content script are shown and re-enable the button', () => { + const { elements, listeners, status, sendRuntimeMessage } = createPopupContext(); + elements.get('wealthAccountsCheckbox').checked = true; + listeners.get('export-btn:click')(); + + sendRuntimeMessage({ action: 'exportFinished', status: 'error', message: 'Error during data export: boom' }); + + assert.equal(elements.get('export-btn').disabled, false); + assert.equal(status().text, 'Error during data export: boom'); + assert.equal(status().kind, 'error'); +}); + +test('unrelated runtime messages are ignored', () => { + const { elements, listeners, status, sendRuntimeMessage } = createPopupContext(); + elements.get('wealthAccountsCheckbox').checked = true; + listeners.get('export-btn:click')(); + + sendRuntimeMessage({ action: 'somethingElse' }); + + assert.equal(elements.get('export-btn').disabled, true); + assert.equal(status().kind, 'info'); +}); + +test('quick date ranges cover the expected days', () => { + const { context } = createPopupContext(); + const now = new Date(2026, 6, 30); // July 30, 2026 + const range = name => { + const { start, end } = context.quickRangeValues(name, now); + return `${start}..${end}`; + }; + + assert.equal(range('last-30'), '2026-06-30..2026-07-30'); + assert.equal(range('last-90'), '2026-05-01..2026-07-30'); + assert.equal(range('ytd'), '2026-01-01..2026-07-30'); + assert.equal(range('last-month'), '2026-06-01..2026-06-30'); + assert.equal(range('last-year'), '2025-01-01..2025-12-31'); +}); + +test('quick date ranges handle month and year boundaries', () => { + const { context } = createPopupContext(); + const march = new Date(2026, 2, 1); + const january = new Date(2026, 0, 5); + const range = (name, now) => { + const { start, end } = context.quickRangeValues(name, now); + return `${start}..${end}`; + }; + + assert.equal(range('last-30', march), '2026-01-30..2026-03-01'); + assert.equal(range('last-month', january), '2025-12-01..2025-12-31'); + assert.equal(range('last-90', january), '2025-10-07..2026-01-05'); + assert.equal(context.quickRangeValues('nonsense', march), null); +});