diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index c0df71b..3554bcc 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -1349,7 +1349,8 @@ router.get('/task', async (req, res) => { const serializedTask = await serializeTask( task, - req.currentUser.timezone + req.currentUser.timezone, + { skipDisplayNameTransform: true } ); res.json(serializedTask); diff --git a/backend/tests/integration/inbox.test.js b/backend/tests/integration/inbox.test.js index 3956daf..10a466e 100644 --- a/backend/tests/integration/inbox.test.js +++ b/backend/tests/integration/inbox.test.js @@ -146,6 +146,82 @@ describe('Inbox Routes', () => { expect(response.status).toBe(401); expect(response.body.error).toBe('Authentication required'); }); + + it('should support pagination with limit and offset', async () => { + // Create 25 inbox items + const items = []; + for (let i = 1; i <= 25; i++) { + const item = await InboxItem.create({ + content: `Item ${i}`, + status: 'added', + source: 'test', + user_id: user.id, + }); + items.push(item); + // Small delay to ensure different timestamps + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + // Test first page (default limit of 20) + const response1 = await agent.get('/api/inbox?limit=20&offset=0'); + expect(response1.status).toBe(200); + expect(response1.body.items.length).toBe(20); + expect(response1.body.pagination.total).toBe(26); // 25 + 1 from beforeEach + expect(response1.body.pagination.hasMore).toBe(true); + expect(response1.body.pagination.offset).toBe(0); + expect(response1.body.pagination.limit).toBe(20); + + // Test second page + const response2 = await agent.get('/api/inbox?limit=20&offset=20'); + expect(response2.status).toBe(200); + expect(response2.body.items.length).toBe(6); // Remaining items + expect(response2.body.pagination.total).toBe(26); + expect(response2.body.pagination.hasMore).toBe(false); + expect(response2.body.pagination.offset).toBe(20); + }); + + it('should support loading more than 20 items at once', async () => { + // Create 30 inbox items + for (let i = 1; i <= 30; i++) { + await InboxItem.create({ + content: `Item ${i}`, + status: 'added', + source: 'test', + user_id: user.id, + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + // Request 40 items (should get all 31: 30 + 1 from beforeEach) + const response = await agent.get('/api/inbox?limit=40&offset=0'); + expect(response.status).toBe(200); + expect(response.body.items.length).toBe(31); + expect(response.body.pagination.total).toBe(31); + expect(response.body.pagination.hasMore).toBe(false); + expect(response.body.pagination.limit).toBe(40); + }); + + it('should return items in newest-first order when paginating', async () => { + // Create 25 inbox items + for (let i = 1; i <= 25; i++) { + await InboxItem.create({ + content: `Item ${i}`, + status: 'added', + source: 'test', + user_id: user.id, + }); + await new Promise((resolve) => setTimeout(resolve, 5)); + } + + // Get first page + const response = await agent.get('/api/inbox?limit=20&offset=0'); + expect(response.status).toBe(200); + + // Verify newest items are first + const items = response.body.items; + expect(items[0].content).toBe('Item 25'); // Newest + expect(items[19].content).toBe('Item 6'); // 20th item + }); }); describe('GET /api/inbox/:uid', () => { diff --git a/backend/tests/integration/recurring-display-fixes.test.js b/backend/tests/integration/recurring-display-fixes.test.js index cd79170..b260d9d 100644 --- a/backend/tests/integration/recurring-display-fixes.test.js +++ b/backend/tests/integration/recurring-display-fixes.test.js @@ -274,4 +274,46 @@ describe('Recurring Task Display Fixes', () => { expect(taskNames).toContain('Daily'); }); }); + + describe('Task By UID Endpoint', () => { + it('should return actual task name when fetching recurring task by UID', async () => { + const recurringTask = await Task.create({ + name: 'My Weekly Review', + user_id: user.id, + recurrence_type: 'weekly', + recurring_parent_id: null, + status: Task.STATUS.NOT_STARTED, + priority: Task.PRIORITY.MEDIUM, + }); + + const response = await agent.get( + `/api/task?uid=${recurringTask.uid}` + ); + + expect(response.status).toBe(200); + expect(response.body.name).toBe('My Weekly Review'); + expect(response.body.original_name).toBe('My Weekly Review'); + expect(response.body.recurrence_type).toBe('weekly'); + }); + + it('should return actual task name for monthly recurring task by UID', async () => { + const monthlyTask = await Task.create({ + name: 'Monthly Budget Review', + user_id: user.id, + recurrence_type: 'monthly', + recurring_parent_id: null, + status: Task.STATUS.NOT_STARTED, + priority: Task.PRIORITY.MEDIUM, + }); + + const response = await agent.get( + `/api/task?uid=${monthlyTask.uid}` + ); + + expect(response.status).toBe(200); + expect(response.body.name).toBe('Monthly Budget Review'); + expect(response.body.original_name).toBe('Monthly Budget Review'); + expect(response.body.recurrence_type).toBe('monthly'); + }); + }); }); diff --git a/e2e/tests/recurring-task.spec.ts b/e2e/tests/recurring-task.spec.ts new file mode 100644 index 0000000..1bceeae --- /dev/null +++ b/e2e/tests/recurring-task.spec.ts @@ -0,0 +1,212 @@ +import { test, expect } from '@playwright/test'; + +// Shared login function +async function loginAndNavigateToTasks(page, baseURL) { + const appUrl = baseURL ?? process.env.APP_URL ?? 'http://localhost:8080'; + + // Go directly to login page first + await page.goto(appUrl + '/login'); + + // Fill credentials and login + const email = process.env.E2E_EMAIL || 'test@tududi.com'; + const password = process.env.E2E_PASSWORD || 'password123'; + + await page.getByLabel('Email').fill(email); + await page.getByLabel('Password').fill(password); + await page.getByRole('button', { name: /login/i }).click(); + + // Wait for redirect to Today view + await expect(page).toHaveURL(/\/today$/); + + // Navigate to tasks page + await page.goto(appUrl + '/tasks'); + await expect(page).toHaveURL(/\/tasks/); + + // Wait for the tasks page to fully load by waiting for the task input to be visible + await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 }); + + return appUrl; +} + +// Helper function to create a recurring task via API +async function createRecurringTaskViaAPI(page, taskName: string, recurrenceType: string) { + // Use the browser context to make an API call + const response = await page.request.post('/api/task', { + data: { + name: taskName, + recurrence_type: recurrenceType, + status: 'not_started', + priority: 'medium' + } + }); + + expect(response.ok()).toBeTruthy(); + const task = await response.json(); + return task; +} + +test('recurring task displays actual name (not "Weekly") after page refresh', async ({ page, baseURL }) => { + const appUrl = await loginAndNavigateToTasks(page, baseURL); + + // Create a unique recurring task + const timestamp = Date.now(); + const taskName = `My Weekly Review ${timestamp}`; + + // Create a weekly recurring task via API + const task = await createRecurringTaskViaAPI(page, taskName, 'weekly'); + + // Refresh the page to simulate the bug scenario + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Wait for the tasks page to fully load + await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 }); + + // Click on the recurring task in the task list + const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName }); + await expect(taskInList).toBeVisible({ timeout: 10000 }); + await taskInList.click(); + + // Wait for the task modal/details page to open + // The task modal should show the task name input or a heading with the task name + await page.waitForTimeout(1000); + + // Check if we're on the task detail page (URL-based) or modal (overlay) + const currentUrl = page.url(); + + if (currentUrl.includes(`/task/${task.uid}`)) { + // We're on the task detail page + // The title should be displayed correctly in the page heading or task name field + const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]'); + + // The task name should be the actual name, not "Weekly" + const displayedText = await taskNameElement.first().textContent(); + expect(displayedText).toContain(taskName); + expect(displayedText).not.toBe('Weekly'); + } else { + // We're in a modal + await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 }); + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + + // The task name input should show the actual name, not "Weekly" + await expect(taskNameInput).toHaveValue(taskName); + await expect(taskNameInput).not.toHaveValue('Weekly'); + } +}); + +test('monthly recurring task displays actual name (not "Monthly") after page refresh', async ({ page, baseURL }) => { + const appUrl = await loginAndNavigateToTasks(page, baseURL); + + // Create a unique recurring task + const timestamp = Date.now(); + const taskName = `Monthly Budget Review ${timestamp}`; + + // Create a monthly recurring task via API + const task = await createRecurringTaskViaAPI(page, taskName, 'monthly'); + + // Refresh the page to simulate the bug scenario + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Wait for the tasks page to fully load + await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 }); + + // Click on the recurring task in the task list + const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName }); + await expect(taskInList).toBeVisible({ timeout: 10000 }); + await taskInList.click(); + + // Wait for the task modal/details page to open + await page.waitForTimeout(1000); + + // Check if we're on the task detail page (URL-based) or modal (overlay) + const currentUrl = page.url(); + + if (currentUrl.includes(`/task/${task.uid}`)) { + // We're on the task detail page + const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]'); + + // The task name should be the actual name, not "Monthly" + const displayedText = await taskNameElement.first().textContent(); + expect(displayedText).toContain(taskName); + expect(displayedText).not.toBe('Monthly'); + } else { + // We're in a modal + await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 }); + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + + // The task name input should show the actual name, not "Monthly" + await expect(taskNameInput).toHaveValue(taskName); + await expect(taskNameInput).not.toHaveValue('Monthly'); + } +}); + +test('daily recurring task displays actual name (not "Daily") after page refresh', async ({ page, baseURL }) => { + const appUrl = await loginAndNavigateToTasks(page, baseURL); + + // Create a unique recurring task + const timestamp = Date.now(); + const taskName = `Daily Standup ${timestamp}`; + + // Create a daily recurring task via API + const task = await createRecurringTaskViaAPI(page, taskName, 'daily'); + + // Refresh the page to simulate the bug scenario + await page.reload(); + await page.waitForLoadState('networkidle'); + + // Wait for the tasks page to fully load + await expect(page.locator('[data-testid="new-task-input"]')).toBeVisible({ timeout: 10000 }); + + // Click on the recurring task in the task list + const taskInList = page.locator('[data-testid*="task-item"]').filter({ hasText: taskName }); + await expect(taskInList).toBeVisible({ timeout: 10000 }); + await taskInList.click(); + + // Wait for the task modal/details page to open + await page.waitForTimeout(1000); + + // Check if we're on the task detail page (URL-based) or modal (overlay) + const currentUrl = page.url(); + + if (currentUrl.includes(`/task/${task.uid}`)) { + // We're on the task detail page + const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]'); + + // The task name should be the actual name, not "Daily" + const displayedText = await taskNameElement.first().textContent(); + expect(displayedText).toContain(taskName); + expect(displayedText).not.toBe('Daily'); + } else { + // We're in a modal + await expect(page.locator('[data-testid="task-name-input"]')).toBeVisible({ timeout: 5000 }); + const taskNameInput = page.locator('[data-testid="task-name-input"]'); + + // The task name input should show the actual name, not "Daily" + await expect(taskNameInput).toHaveValue(taskName); + await expect(taskNameInput).not.toHaveValue('Daily'); + } +}); + +test('recurring task shows correct name without visiting Today page first', async ({ page, baseURL }) => { + const appUrl = await loginAndNavigateToTasks(page, baseURL); + + // Create a unique recurring task + const timestamp = Date.now(); + const taskName = `Weekly Planning ${timestamp}`; + + // Create a weekly recurring task via API + const task = await createRecurringTaskViaAPI(page, taskName, 'weekly'); + + // Navigate directly to the task detail page using the task UID + await page.goto(`${appUrl}/task/${task.uid}`); + await page.waitForLoadState('networkidle'); + + // The task detail page should show the actual name, not "Weekly" + const taskNameElement = page.locator('h1, h2, [data-testid="task-name-input"]'); + await expect(taskNameElement.first()).toBeVisible({ timeout: 5000 }); + + const displayedText = await taskNameElement.first().textContent(); + expect(displayedText).toContain(taskName); + expect(displayedText).not.toBe('Weekly'); +}); diff --git a/frontend/components/Inbox/InboxItems.tsx b/frontend/components/Inbox/InboxItems.tsx index bc47cf1..9aeb811 100644 --- a/frontend/components/Inbox/InboxItems.tsx +++ b/frontend/components/Inbox/InboxItems.tsx @@ -1,4 +1,5 @@ import React, { useState, useEffect, useMemo } from 'react'; +import { useSearchParams } from 'react-router-dom'; import { Task } from '../../entities/Task'; import { Project } from '../../entities/Project'; import { Note } from '../../entities/Note'; @@ -29,6 +30,10 @@ import { useStore } from '../../store/useStore'; const InboxItems: React.FC = () => { const { t } = useTranslation(); const { showSuccessToast, showErrorToast } = useToast(); + const [searchParams, setSearchParams] = useSearchParams(); + + // Track if we've done the initial load from URL + const [hasInitialized, setHasInitialized] = useState(false); // Access store data const { inboxItems, isLoading, pagination } = useStore( @@ -80,8 +85,13 @@ const InboxItems: React.FC = () => { ); useEffect(() => { - // Initial data loading - loadInboxItemsToStore(true); + // Read the page size from URL parameter or use default + const urlPageSize = searchParams.get('loaded'); + const currentLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 20; + + // Initial data loading - load the amount specified in URL + loadInboxItemsToStore(true, currentLoadedCount); + setHasInitialized(true); // Load projects initially const loadInitialProjects = async () => { @@ -122,7 +132,9 @@ const InboxItems: React.FC = () => { const handleForceReload = () => { // Wait a short time to ensure the backend has processed the new item setTimeout(() => { - loadInboxItemsToStore(false); // Don't show loading state during forced reload + const currentInboxStore = useStore.getState().inboxStore; + const currentCount = currentInboxStore.inboxItems.length; + loadInboxItemsToStore(false, currentCount); // Preserve current loaded count }, 500); }; @@ -162,7 +174,9 @@ const InboxItems: React.FC = () => { // This ensures real-time updates when items are added externally // Use a reasonable interval that balances responsiveness with performance const pollInterval = setInterval(() => { - loadInboxItemsToStore(false); // Don't show loading state during polling + const currentInboxStore = useStore.getState().inboxStore; + const currentCount = currentInboxStore.inboxItems.length; + loadInboxItemsToStore(false, currentCount); // Preserve current loaded count }, 15000); // Check for new items every 15 seconds // Add event listeners @@ -182,6 +196,26 @@ const InboxItems: React.FC = () => { }; }, [t, showSuccessToast]); // Include dependencies that are actually used + // Update URL when inboxItems count changes (but only after initialization) + useEffect(() => { + // Don't update URL until we've done the initial load from URL + if (!hasInitialized) return; + + const urlPageSize = searchParams.get('loaded'); + const urlLoadedCount = urlPageSize ? parseInt(urlPageSize, 10) : 0; + + // Only update URL if the count has actually changed from what's in the URL + if (inboxItems.length > 20 && inboxItems.length !== urlLoadedCount) { + setSearchParams( + { loaded: inboxItems.length.toString() }, + { replace: true } + ); + } else if (inboxItems.length <= 20 && urlLoadedCount > 0) { + // Remove the parameter if we're at the default page size + setSearchParams({}, { replace: true }); + } + }, [inboxItems.length, hasInitialized]); // Track hasInitialized to prevent premature URL updates + const handleProcessItem = async ( uid: string, showToast: boolean = true diff --git a/frontend/utils/inboxService.ts b/frontend/utils/inboxService.ts index 215d6ca..9f9929c 100644 --- a/frontend/utils/inboxService.ts +++ b/frontend/utils/inboxService.ts @@ -118,7 +118,8 @@ const lastCheckTimestamp = Date.now(); // Store-aware functions export const loadInboxItemsToStore = async ( - isInitialLoad: boolean = false + isInitialLoad: boolean = false, + requestedCount: number = 20 ): Promise => { const inboxStore = useStore.getState().inboxStore; // Only show loading for initial load, not for polling @@ -128,7 +129,8 @@ export const loadInboxItemsToStore = async ( } try { - const { items, pagination } = await fetchInboxItems(20, 0); // Always load first page for refresh + // Load the requested number of items (for pagination preservation) + const { items, pagination } = await fetchInboxItems(requestedCount, 0); // Check for new items since last check (only for non-initial loads) if (!isInitialLoad) {