diff --git a/frontend/src/__tests__/app.test.ts b/frontend/src/__tests__/app.test.ts
index 7a59b771..731f4eaf 100644
--- a/frontend/src/__tests__/app.test.ts
+++ b/frontend/src/__tests__/app.test.ts
@@ -163,6 +163,63 @@ describe('App Module', () => {
});
describe('setupEventListeners', () => {
+ describe('feedback context', () => {
+ let originalURL: string;
+ let link: HTMLAnchorElement;
+
+ beforeEach(() => {
+ originalURL = window.location.href;
+ jest.useFakeTimers().setSystemTime(new Date('2026-10-13T23:59:00Z'));
+ window.history.replaceState(null, '', '/purchases');
+ document.body.innerHTML = 'Feedback';
+ link = document.getElementById('feedback-link') as HTMLAnchorElement;
+ link.addEventListener('click', event => event.preventDefault());
+ setupEventListeners();
+ });
+
+ afterEach(() => {
+ jest.useRealTimers();
+ jest.restoreAllMocks();
+ window.history.replaceState(null, '', originalURL);
+ });
+
+ function expectContext(date: string): void {
+ const params = new URLSearchParams(link.href.split('?')[1]);
+ expect(link.href).toMatch(/^mailto:/);
+ expect(params.get('subject')).toBe('CUDly Feedback');
+ expect(params.get('body')).toContain('## Steps to Reproduce (for bugs)');
+ expect(params.get('body')).toContain(`- Browser: ${navigator.userAgent}`);
+ expect(params.get('body')).toContain(`- URL: ${window.location.href}`);
+ expect(params.get('body')).toContain(`- Date: ${date}`);
+ }
+
+ test('provides initial feedback context', () => {
+ expectContext('2026-10-13T23:59:00.000Z');
+ });
+
+ test.each(['/plans', '/home', '/opportunities?filter=a%26b%2Bc#details'])('uses the current %s URL and date when activated', path => {
+ link.focus();
+ window.history.pushState(null, '', path);
+ jest.setSystemTime(new Date('2026-10-14T00:01:00Z'));
+ link.click();
+ expectContext('2026-10-14T00:01:00.000Z');
+ });
+
+ test.each(['focus', 'contextmenu'])('refreshes context on %s for link inspection', event => {
+ window.history.pushState(null, '', '/plans');
+ jest.setSystemTime(new Date('2026-10-14T00:02:00Z'));
+ link.dispatchEvent(new Event(event, { cancelable: true }));
+ expectContext('2026-10-14T00:02:00.000Z');
+ });
+
+ test('refreshes once after repeated setup', () => {
+ setupEventListeners();
+ const writes = jest.spyOn(link, 'href', 'set');
+ link.click();
+ expect(writes).toHaveBeenCalledTimes(1);
+ });
+ });
+
test('sets up tab button click handlers', () => {
document.body.innerHTML = `
diff --git a/frontend/src/app.ts b/frontend/src/app.ts
index b69b8205..f90a482d 100644
--- a/frontend/src/app.ts
+++ b/frontend/src/app.ts
@@ -919,7 +919,8 @@ function setupFeedbackLink(): void {
const feedbackEmail = 'contact@leanercloud.com';
const subject = 'CUDly Feedback';
- const body = `Hi CUDly Team,
+ const refreshFeedbackLink = (): void => {
+ const body = `Hi CUDly Team,
I'd like to share some feedback about CUDly:
@@ -967,6 +968,12 @@ Please attach any relevant screenshots to this email.
Thank you for helping us improve CUDly!
`;
- const mailtoUrl = `mailto:${feedbackEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
- feedbackLink.href = mailtoUrl;
+ const mailtoUrl = `mailto:${feedbackEmail}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
+ feedbackLink.href = mailtoUrl;
+ };
+
+ refreshFeedbackLink();
+ feedbackLink.onclick = refreshFeedbackLink;
+ feedbackLink.onfocus = refreshFeedbackLink;
+ feedbackLink.oncontextmenu = refreshFeedbackLink;
}
diff --git a/frontend/tests-e2e/feedback-link.spec.ts b/frontend/tests-e2e/feedback-link.spec.ts
new file mode 100644
index 00000000..63bf5a72
--- /dev/null
+++ b/frontend/tests-e2e/feedback-link.spec.ts
@@ -0,0 +1,75 @@
+import { test, expect, type Page } from '@playwright/test';
+import { mockApi, seedAuth } from './fixtures/recs';
+
+async function expectFeedbackContext(page: Page, date: string): Promise {
+ const href = await page.locator('#feedback-link').getAttribute('href');
+ const params = new URLSearchParams(href?.split('?')[1]);
+ expect(href).toMatch(/^mailto:/);
+ expect(params.get('subject')).toBe('CUDly Feedback');
+ expect(params.get('body')).toContain(`- URL: ${page.url()}`);
+ expect(params.get('body')).toContain(`- Date: ${date}`);
+ expect(params.get('body')).toContain('## Steps to Reproduce (for bugs)');
+}
+
+test('feedback uses the current SPA route and time on mouse, keyboard, focus and context menu', async ({ page }) => {
+ const pageErrors: string[] = [];
+ const consoleErrors: string[] = [];
+ const failedRequests: string[] = [];
+ page.on('pageerror', error => pageErrors.push(String(error)));
+ page.on('console', message => {
+ if (message.type() === 'error') consoleErrors.push(message.text());
+ });
+ page.on('response', response => {
+ if (response.status() >= 400) failedRequests.push(`${response.status()} ${new URL(response.url()).pathname}`);
+ });
+ page.on('requestfailed', request => failedRequests.push(new URL(request.url()).pathname));
+ await seedAuth(page);
+ await mockApi(page);
+ await page.route('**/api/history?*', route => route.fulfill({ json: [] }));
+ await page.route('**/api/history/analytics?*', route => route.fulfill({ json: {
+ start: '2026-10-01T00:00:00Z', end: '2026-10-14T00:00:00Z', interval: 'day',
+ summary: { total_period_savings: 0, total_upfront_spent: 0, purchase_count: 0, average_savings_per_period: 0, peak_savings: 0 },
+ data_points: [],
+ } }));
+ await page.route('**/api/purchases/planned', route => route.fulfill({ json: { purchases: [] } }));
+ await page.route('**/api/dashboard/upcoming', route => route.fulfill({ json: [] }));
+ await page.route('**/api/dashboard/summary?*', route => route.fulfill({ json: {
+ potential_monthly_savings: 0, total_recommendations: 0, active_commitments: 0,
+ committed_monthly: 0, current_coverage: 0, target_coverage: 0, ytd_savings: 0, by_service: {},
+ } }));
+ await page.clock.setFixedTime(new Date('2026-10-13T23:59:00Z'));
+ await page.goto('/purchases');
+ await expect(page.locator('#purchases-tab.active')).toBeVisible();
+ const originalDocument = await page.evaluate(() => performance.timeOrigin);
+ const feedback = page.locator('#feedback-link');
+ await expectFeedbackContext(page, '2026-10-13T23:59:00.000Z');
+ await feedback.evaluate(link => {
+ link.addEventListener('click', event => event.preventDefault());
+ link.addEventListener('contextmenu', event => event.preventDefault());
+ });
+
+ for (const tab of ['plans', 'home', 'opportunities']) {
+ await page.locator(`#sidebar [data-tab="${tab}"]`).click();
+ await expect(page.locator(`#${tab}-tab.active`)).toBeVisible();
+ await feedback.focus();
+ await page.clock.setFixedTime(new Date('2026-10-14T00:01:00Z'));
+ await feedback.press('Enter');
+ await expectFeedbackContext(page, '2026-10-14T00:01:00.000Z');
+ await page.clock.setFixedTime(new Date('2026-10-14T00:02:00Z'));
+ await feedback.click();
+ await expectFeedbackContext(page, '2026-10-14T00:02:00.000Z');
+ expect(await page.evaluate(() => performance.timeOrigin)).toBe(originalDocument);
+ }
+
+ await page.evaluate(() => window.history.pushState(null, '', '/opportunities?filter=a%26b%2Bc#details'));
+ await page.clock.setFixedTime(new Date('2026-10-14T00:03:00Z'));
+ await feedback.click({ button: 'right' });
+ await expectFeedbackContext(page, '2026-10-14T00:03:00.000Z');
+ await page.locator('#sidebar [data-tab="home"]').focus();
+ await page.clock.setFixedTime(new Date('2026-10-14T00:04:00Z'));
+ await feedback.focus();
+ await expectFeedbackContext(page, '2026-10-14T00:04:00.000Z');
+ expect(pageErrors).toEqual([]);
+ expect(failedRequests).toEqual([]);
+ expect(consoleErrors).toEqual([]);
+});