From 119b04acffa57d6702457c47f13ee677e4c184e1 Mon Sep 17 00:00:00 2001 From: Chris Date: Tue, 7 Oct 2025 17:10:33 +0300 Subject: [PATCH 1/4] Fix inbox items refresh (#398) * Fix inbox items refresh * fixup! Fix inbox items refresh --- backend/routes/tasks.js | 3 +- backend/tests/integration/inbox.test.js | 76 +++++++ .../recurring-display-fixes.test.js | 42 ++++ e2e/tests/recurring-task.spec.ts | 212 ++++++++++++++++++ frontend/components/Inbox/InboxItems.tsx | 42 +++- frontend/utils/inboxService.ts | 6 +- 6 files changed, 374 insertions(+), 7 deletions(-) create mode 100644 e2e/tests/recurring-task.spec.ts 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) { From abb1cb875b1454ccba94f326ecd3a15e696bf9f1 Mon Sep 17 00:00:00 2001 From: Chris Veleris Date: Tue, 7 Oct 2025 17:14:21 +0300 Subject: [PATCH 2/4] release: v0.83.2 --- package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/package.json b/package.json index cf3f865..ead7d6c 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "tududi", - "version": "v0.83.1", + "version": "v0.83.2", "description": "Self-hosted task management with hierarchical organization, multi-language support, and Telegram integration.", "directories": { "test": "test" From 6efb565a4e3ba2d5001f41edb059c2cdba79415e Mon Sep 17 00:00:00 2001 From: Chris Date: Wed, 8 Oct 2025 17:46:20 +0300 Subject: [PATCH 3/4] Feat/persist project view (#401) * Persist project view selection * fixup! Persist project view selection --- frontend/components/Projects.tsx | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/frontend/components/Projects.tsx b/frontend/components/Projects.tsx index 0f93138..6baa570 100644 --- a/frontend/components/Projects.tsx +++ b/frontend/components/Projects.tsx @@ -54,7 +54,10 @@ const Projects: React.FC = () => { useState(false); const [activeDropdown, setActiveDropdown] = useState(null); const [searchQuery, setSearchQuery] = useState(''); - const [viewMode, setViewMode] = useState<'cards' | 'list'>('cards'); + const [viewMode, setViewMode] = useState<'cards' | 'list'>(() => { + const saved = localStorage.getItem('projectsViewMode'); + return saved === 'list' || saved === 'cards' ? saved : 'cards'; + }); const [isSearchExpanded, setIsSearchExpanded] = useState(false); const [orderBy, setOrderBy] = useState('created_at:desc'); @@ -119,6 +122,11 @@ const Projects: React.FC = () => { loadAreas(); }, []); + // Persist viewMode to localStorage + useEffect(() => { + localStorage.setItem('projectsViewMode', viewMode); + }, [viewMode]); + // Projects are now loaded by Layout component into global store // Modal state tracking removed after fixing the issue From a81ca2f2b6d5533be5d0ccf5cb8a8a207f65dd6a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 11 Oct 2025 00:08:13 +0300 Subject: [PATCH 4/4] Fix upcoming completed issue (#404) * Fix upcoming completed issue * fixup! Fix upcoming completed issue * fixup! fixup! Fix upcoming completed issue * Fix completed icon plscement * fixup! Fix completed icon plscement * Add upcoming section tests --- ...20250920075905-convert-active-to-states.js | 59 ++++++-- backend/routes/tasks.js | 39 ++++- backend/utils/timezone-utils.js | 1 + e2e/tests/upcoming-task.spec.ts | 37 +++++ frontend/components/Task/GroupedTaskList.tsx | 48 +++--- frontend/components/Task/TaskItem.tsx | 22 ++- frontend/components/Task/TaskPriorityIcon.tsx | 11 +- frontend/components/Tasks.tsx | 141 ++++++++++-------- tailwind.config.js | 18 ++- 9 files changed, 265 insertions(+), 111 deletions(-) create mode 100644 e2e/tests/upcoming-task.spec.ts diff --git a/backend/migrations/20250920075905-convert-active-to-states.js b/backend/migrations/20250920075905-convert-active-to-states.js index f3bd449..f71ef65 100644 --- a/backend/migrations/20250920075905-convert-active-to-states.js +++ b/backend/migrations/20250920075905-convert-active-to-states.js @@ -2,24 +2,51 @@ module.exports = { up: async (queryInterface, Sequelize) => { - // Update all projects: active=true -> state='in_progress', active=false -> state='idea' - await queryInterface.sequelize.query(` - UPDATE projects - SET state = CASE - WHEN active = 1 THEN 'in_progress' - ELSE 'idea' - END - `); + try { + const tableInfo = await queryInterface.describeTable('projects'); + + // Only migrate if 'active' column exists + if ('active' in tableInfo) { + await queryInterface.sequelize.query(` + UPDATE projects + SET state = CASE + WHEN active = 1 THEN 'in_progress' + ELSE 'idea' + END + `); + } else { + console.log( + 'Column active does not exist in projects table, skipping data migration' + ); + } + } catch (error) { + console.log( + 'Migration error converting active to states:', + error.message + ); + // Don't throw - allow migration to continue since state column already exists + } }, down: async (queryInterface, Sequelize) => { - // Reverse the conversion: state='in_progress' -> active=true, others -> active=false - await queryInterface.sequelize.query(` - UPDATE projects - SET active = CASE - WHEN state = 'in_progress' THEN 1 - ELSE 0 - END - `); + try { + const tableInfo = await queryInterface.describeTable('projects'); + + // Only rollback if 'active' column exists + if ('active' in tableInfo) { + await queryInterface.sequelize.query(` + UPDATE projects + SET active = CASE + WHEN state = 'in_progress' THEN 1 + ELSE 0 + END + `); + } + } catch (error) { + console.log( + 'Migration rollback error for active column:', + error.message + ); + } }, }; diff --git a/backend/routes/tasks.js b/backend/routes/tasks.js index 3554bcc..1df6b97 100644 --- a/backend/routes/tasks.js +++ b/backend/routes/tasks.js @@ -457,7 +457,8 @@ async function filterTasksByParams(params, userId, userTimezone) { // Disable search functionality for upcoming view if (params.type === 'upcoming') { // Remove search-related parameters to prevent search functionality - params = { ...params, client_side_filtering: false }; + // Keep client_side_filtering to allow frontend to control completed task visibility + params = { ...params }; delete params.search; } @@ -563,16 +564,24 @@ async function filterTasksByParams(params, userId, userTimezone) { const safeTimezone = getSafeTimezone(userTimezone); const upcomingRange = getUpcomingRangeInUTC(safeTimezone, 7); + console.log('📅 UPCOMING RANGE DEBUG:'); + console.log(` Timezone: ${safeTimezone}`); + console.log( + ` Range Start (UTC): ${upcomingRange.start.toISOString()}` + ); + console.log( + ` Range End (UTC): ${upcomingRange.end.toISOString()}` + ); + console.log(` User ID: ${userId}`); + // For upcoming view, we want to show recurring instances (children) with due dates // Override the default whereClause to include recurring instances - // NOTE: Search functionality is disabled for upcoming view - ignore client_side_filtering whereClause = { user_id: userId, parent_task_id: null, // Exclude subtasks from main task lists due_date: { [Op.between]: [upcomingRange.start, upcomingRange.end], }, - status: { [Op.notIn]: [Task.STATUS.DONE, 'done'] }, [Op.or]: [ // Include non-recurring tasks { @@ -595,6 +604,15 @@ async function filterTasksByParams(params, userId, userTimezone) { }, ], }; + + // Apply status filter based on client_side_filtering + if (params.status === 'done') { + whereClause.status = { [Op.in]: [Task.STATUS.DONE, 'done'] }; + } else if (!params.client_side_filtering) { + // Only exclude completed tasks if not doing client-side filtering + whereClause.status = { [Op.notIn]: [Task.STATUS.DONE, 'done'] }; + } + // If client_side_filtering is true, don't add any status filter (include all) break; } case 'next': @@ -1178,11 +1196,16 @@ router.get('/tasks', async (req, res) => { // Debug logging for upcoming view if (req.query.type === 'upcoming') { console.log('🔍 UPCOMING TASKS DEBUG:'); - tasks.forEach((task) => { - console.log( - `- ID: ${task.id}, Name: "${task.name}", Due: ${task.due_date}, Recur: ${task.recurrence_type}, Parent: ${task.recurring_parent_id}` - ); - }); + console.log(` Total tasks returned: ${tasks.length}`); + if (tasks.length > 0) { + tasks.forEach((task) => { + console.log( + `- ID: ${task.id}, Name: "${task.name}", Due: ${task.due_date}, Recur: ${task.recurrence_type}, Parent: ${task.recurring_parent_id}, Status: ${task.status}` + ); + }); + } else { + console.log(' ⚠️ No tasks matched the query!'); + } } // Group upcoming tasks by day of week if requested diff --git a/backend/utils/timezone-utils.js b/backend/utils/timezone-utils.js index 2d14535..6df25bd 100644 --- a/backend/utils/timezone-utils.js +++ b/backend/utils/timezone-utils.js @@ -71,6 +71,7 @@ function getTodayBoundsInUTC(userTimezone) { /** * Get date range for "upcoming" tasks (next N days) in user timezone + * Includes today through N days ahead * @param {string} userTimezone - User's timezone * @param {number} days - Number of days to look ahead (default: 7) * @returns {Object} { start: Date, end: Date } - UTC Date objects diff --git a/e2e/tests/upcoming-task.spec.ts b/e2e/tests/upcoming-task.spec.ts new file mode 100644 index 0000000..d30f6fe --- /dev/null +++ b/e2e/tests/upcoming-task.spec.ts @@ -0,0 +1,37 @@ +import { test, expect, Page } from '@playwright/test'; + +// Shared login function +async function login(page: Page, baseURL: string | undefined) { + const appUrl = baseURL ?? process.env.APP_URL ?? 'http://localhost:8080'; + + await page.goto(appUrl + '/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(); + + await expect(page).toHaveURL(/\/today$/); + + return appUrl; +} + +test('upcoming view loads and displays upcoming section', async ({ page, baseURL }) => { + const appUrl = await login(page, baseURL); + + // Navigate to upcoming view + await page.goto(appUrl + '/upcoming'); + await expect(page).toHaveURL(/\/upcoming/); + + // Verify the page heading is visible + await expect(page.getByRole('heading', { name: 'Upcoming' })).toBeVisible(); + + // Wait for content to load + await page.waitForTimeout(1000); + + // Verify we don't have the task creation input (upcoming view is read-only) + const taskInput = page.locator('[data-testid="new-task-input"]'); + await expect(taskInput).not.toBeVisible(); +}); diff --git a/frontend/components/Task/GroupedTaskList.tsx b/frontend/components/Task/GroupedTaskList.tsx index 8e7632a..01f056f 100644 --- a/frontend/components/Task/GroupedTaskList.tsx +++ b/frontend/components/Task/GroupedTaskList.tsx @@ -240,27 +240,33 @@ const GroupedTaskList: React.FC = ({ {/* Day column tasks */}
{dayTasks.map((task) => ( - +
+ +
))} {/* Empty state for columns with no tasks */} diff --git a/frontend/components/Task/TaskItem.tsx b/frontend/components/Task/TaskItem.tsx index a116ab2..9691386 100644 --- a/frontend/components/Task/TaskItem.tsx +++ b/frontend/components/Task/TaskItem.tsx @@ -140,6 +140,7 @@ interface TaskItemProps { hideProjectName?: boolean; onToggleToday?: (taskId: number) => Promise; isUpcomingView?: boolean; + showCompletedTasks?: boolean; } const TaskItem: React.FC = ({ @@ -151,6 +152,7 @@ const TaskItem: React.FC = ({ hideProjectName = false, onToggleToday, isUpcomingView = false, + showCompletedTasks = false, }) => { const navigate = useNavigate(); const { t } = useTranslation(); @@ -162,6 +164,7 @@ const TaskItem: React.FC = ({ const [parentTaskModalOpen, setParentTaskModalOpen] = useState(false); const [parentTask, setParentTask] = useState(null); const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false); + const [isAnimatingOut, setIsAnimatingOut] = useState(false); // Subtasks state const [showSubtasks, setShowSubtasks] = useState(false); @@ -313,6 +316,20 @@ const TaskItem: React.FC = ({ const handleToggleCompletion = async () => { if (task.id) { try { + // Check if task is being completed (not uncompleted) + const isCompletingTask = + task.status !== 'done' && + task.status !== 2 && + task.status !== 'archived' && + task.status !== 3; + + // If completing the task in upcoming view and not showing completed tasks, trigger animation + if (isCompletingTask && isUpcomingView && !showCompletedTasks) { + setIsAnimatingOut(true); + // Wait for animation to complete before updating state + await new Promise((resolve) => setTimeout(resolve, 300)); + } + const response = await toggleTaskCompletion(task.id); // Handle the updated task @@ -366,6 +383,7 @@ const TaskItem: React.FC = ({ } } catch (error) { console.error('Error toggling task completion:', error); + setIsAnimatingOut(false); // Reset animation state on error } } }; @@ -411,11 +429,11 @@ const TaskItem: React.FC = ({ return ( <>
= ({ ) { return ( = ({ } else { return (
{ const displayTasks = useMemo(() => { let filteredTasks; - // First filter by completion status - if (showCompleted) { - // Show only completed tasks (done=2 or archived=3) - filteredTasks = tasks.filter( - (task) => - task.status === 'done' || - task.status === 'archived' || - task.status === 2 || - task.status === 3 - ); + // For upcoming view, don't filter by completion status here + // Let GroupedTaskList handle it + if (isUpcomingView) { + filteredTasks = tasks; } else { - // Show only non-completed tasks - exclude done(2) and archived(3) - filteredTasks = tasks.filter( - (task) => - task.status !== 'done' && - task.status !== 'archived' && - task.status !== 2 && - task.status !== 3 - ); - } + // First filter by completion status + if (showCompleted) { + // Show only completed tasks (done=2 or archived=3) + filteredTasks = tasks.filter( + (task) => + task.status === 'done' || + task.status === 'archived' || + task.status === 2 || + task.status === 3 + ); + } else { + // Show only non-completed tasks - exclude done(2) and archived(3) + filteredTasks = tasks.filter( + (task) => + task.status !== 'done' && + task.status !== 'archived' && + task.status !== 2 && + task.status !== 3 + ); + } - // Then filter by search query if provided (skip for upcoming view) - if (taskSearchQuery.trim() && !isUpcomingView) { - const query = taskSearchQuery.toLowerCase(); - filteredTasks = filteredTasks.filter( - (task) => - task.name.toLowerCase().includes(query) || - task.original_name?.toLowerCase().includes(query) || - task.note?.toLowerCase().includes(query) - ); + // Then filter by search query if provided (skip for upcoming view) + if (taskSearchQuery.trim()) { + const query = taskSearchQuery.toLowerCase(); + filteredTasks = filteredTasks.filter( + (task) => + task.name.toLowerCase().includes(query) || + task.original_name?.toLowerCase().includes(query) || + task.note?.toLowerCase().includes(query) + ); + } } return filteredTasks; @@ -328,6 +334,23 @@ const Tasks: React.FC = () => { task.id === updatedTask.id ? updatedTask : task ) ); + + // Also update groupedTasks if they exist + if (groupedTasks) { + setGroupedTasks((prevGroupedTasks) => { + if (!prevGroupedTasks) return null; + + const newGroupedTasks: GroupedTasks = {}; + Object.entries(prevGroupedTasks).forEach( + ([groupName, tasks]) => { + newGroupedTasks[groupName] = tasks.map((task) => + task.id === updatedTask.id ? updatedTask : task + ); + } + ); + return newGroupedTasks; + }); + } }; const handleTaskDelete = async (taskId: number) => { @@ -507,40 +530,38 @@ const Tasks: React.FC = () => { )} - {!isUpcomingView && ( -
- - Show completed - - -
- )} + /> + +