From a81ca2f2b6d5533be5d0ccf5cb8a8a207f65dd6a Mon Sep 17 00:00:00 2001 From: Chris Date: Sat, 11 Oct 2025 00:08:13 +0300 Subject: [PATCH] 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 - - -
- )} + /> + +