From b0041bafe1b73a7ee8c6e3d4492aa9c5e2501d94 Mon Sep 17 00:00:00 2001 From: Chris Date: Sun, 16 Nov 2025 18:00:39 +0200 Subject: [PATCH] Fix today recurring missing (#548) * Expose today task from a recurring series * fixup! Expose today task from a recurring series * fixup! fixup! Expose today task from a recurring series --- backend/routes/tasks/core/serializers.js | 13 ++ backend/routes/tasks/index.js | 36 ++++- backend/routes/tasks/operations/list.js | 2 + .../routes/tasks/queries/query-builders.js | 42 +++++- backend/scripts/generate-today-instances.js | 38 ++++++ backend/scripts/test-query.js | 56 ++++++++ backend/services/recurringTaskService.js | 6 +- .../tests/integration/recurring-tasks.test.js | 124 ++++++++++++++++-- e2e/tests/task-priority-due-date.spec.ts | 44 +++++-- frontend/components/Task/TaskDetails.tsx | 9 +- frontend/components/Task/TaskModal.tsx | 14 +- frontend/entities/Task.ts | 1 + 12 files changed, 341 insertions(+), 44 deletions(-) create mode 100644 backend/scripts/generate-today-instances.js create mode 100644 backend/scripts/test-query.js diff --git a/backend/routes/tasks/core/serializers.js b/backend/routes/tasks/core/serializers.js index f84edee..ee821b2 100644 --- a/backend/routes/tasks/core/serializers.js +++ b/backend/routes/tasks/core/serializers.js @@ -6,6 +6,7 @@ const { getTaskTodayMoveCount, getTaskTodayMoveCounts, } = require('../../../services/taskEventService'); +const taskRepository = require('../../../repositories/TaskRepository'); async function serializeTask( task, @@ -54,11 +55,23 @@ async function serializeTask( } } + let recurringParentUid = null; + if (taskJson.recurring_parent_id) { + const parentTask = await taskRepository.findById( + taskJson.recurring_parent_id, + { + attributes: ['uid'], + } + ); + recurringParentUid = parentTask?.uid || null; + } + return { ...taskWithoutSubtasks, name: displayName, original_name: taskJson.name, uid: task.uid, + recurring_parent_uid: recurringParentUid, due_date: processDueDateForResponse(taskJson.due_date, safeTimezone), tags: taskJson.Tags || [], Project: taskJson.Project diff --git a/backend/routes/tasks/index.js b/backend/routes/tasks/index.js index 252434d..ac4692c 100644 --- a/backend/routes/tasks/index.js +++ b/backend/routes/tasks/index.js @@ -19,7 +19,10 @@ const { logEvent } = require('../../services/taskEventService'); const { serializeTask, serializeTasks } = require('./core/serializers'); const { updateTaskTags } = require('./operations/tags'); const { filterTasksByParams } = require('./queries/query-builders'); -const { getSafeTimezone } = require('../../utils/timezone-utils'); +const { + getSafeTimezone, + getTodayBoundsInUTC, +} = require('../../utils/timezone-utils'); const { validateProjectAccess, @@ -76,7 +79,36 @@ router.get('/tasks', async (req, res) => { await handleRecurringTasks(userId, type); - const tasks = await filterTasksByParams(req.query, userId, timezone); + let tasks = await filterTasksByParams(req.query, userId, timezone); + + // For type=today, exclude templates that have instances with due_date in today's range + if (type === 'today') { + const safeTimezone = getSafeTimezone(timezone); + const todayBounds = getTodayBoundsInUTC(safeTimezone); + + // Find all instances with due_date in today's range + const instancesForToday = tasks.filter( + (t) => + t.recurring_parent_id && + t.due_date && + new Date(t.due_date) >= todayBounds.start && + new Date(t.due_date) <= todayBounds.end + ); + + // Get parent IDs of those instances + const parentIdsWithTodayInstances = new Set( + instancesForToday.map((t) => t.recurring_parent_id) + ); + + // Filter out templates that have instances for today + tasks = tasks.filter( + (t) => + !t.recurrence_type || + t.recurrence_type === 'none' || + t.recurring_parent_id !== null || + !parentIdsWithTodayInstances.has(t.id) + ); + } const groupedTasks = await buildGroupedTasks( tasks, diff --git a/backend/routes/tasks/operations/list.js b/backend/routes/tasks/operations/list.js index b979f95..8802a9f 100644 --- a/backend/routes/tasks/operations/list.js +++ b/backend/routes/tasks/operations/list.js @@ -8,6 +8,8 @@ const { computeTaskMetrics } = require('../queries/metrics-computation'); async function handleRecurringTasks(userId, queryType) { if (queryType === 'upcoming') { await generateRecurringTasksWithLock(userId, 7); + } else if (queryType === 'today') { + await generateRecurringTasksWithLock(userId, 1); } } diff --git a/backend/routes/tasks/queries/query-builders.js b/backend/routes/tasks/queries/query-builders.js index 786db79..75b54a2 100644 --- a/backend/routes/tasks/queries/query-builders.js +++ b/backend/routes/tasks/queries/query-builders.js @@ -4,6 +4,7 @@ const permissionsService = require('../../../services/permissionsService'); const { getSafeTimezone, getUpcomingRangeInUTC, + getTodayBoundsInUTC, } = require('../../../utils/timezone-utils'); async function filterTasksByParams( @@ -101,8 +102,10 @@ async function filterTasksByParams( ]; switch (params.type) { - case 'today': - whereClause.recurring_parent_id = null; + case 'today': { + const safeTimezone = getSafeTimezone(userTimezone); + const todayBounds = getTodayBoundsInUTC(safeTimezone); + whereClause.status = { [Op.notIn]: [ Task.STATUS.DONE, @@ -111,7 +114,42 @@ async function filterTasksByParams( 'archived', ], }; + whereClause[Op.or] = [ + { + [Op.and]: [ + { + [Op.or]: [ + { recurrence_type: 'none' }, + { recurrence_type: null }, + ], + }, + { recurring_parent_id: null }, + ], + }, + { + [Op.and]: [ + { recurrence_type: { [Op.ne]: 'none' } }, + { recurrence_type: { [Op.ne]: null } }, + { recurring_parent_id: null }, + { today: true }, + ], + }, + { + [Op.and]: [ + { recurring_parent_id: { [Op.ne]: null } }, + { + due_date: { + [Op.between]: [ + todayBounds.start, + todayBounds.end, + ], + }, + }, + ], + }, + ]; break; + } case 'upcoming': { const safeTimezone = getSafeTimezone(userTimezone); const upcomingRange = getUpcomingRangeInUTC(safeTimezone, 7); diff --git a/backend/scripts/generate-today-instances.js b/backend/scripts/generate-today-instances.js new file mode 100644 index 0000000..f779e02 --- /dev/null +++ b/backend/scripts/generate-today-instances.js @@ -0,0 +1,38 @@ +const { + generateRecurringTasksWithLock, +} = require('../services/recurringTaskService'); +const { User } = require('../models'); + +async function generateTasks() { + // Get first user + const user = await User.findOne(); + + if (!user) { + console.log('No users found'); + process.exit(1); + } + + console.log( + `Generating recurring tasks for user ${user.id} (${user.email})` + ); + + const tasks = await generateRecurringTasksWithLock(user.id, 1); + + console.log(`Generated ${tasks.length} task instances`); + + if (tasks.length > 0) { + console.log('\nGenerated tasks:'); + tasks.forEach((t) => { + console.log( + `- ${t.name} (due: ${t.due_date ? t.due_date.toISOString().split('T')[0] : 'none'})` + ); + }); + } + + process.exit(0); +} + +generateTasks().catch((err) => { + console.error('Error:', err); + process.exit(1); +}); diff --git a/backend/scripts/test-query.js b/backend/scripts/test-query.js new file mode 100644 index 0000000..0b1cc51 --- /dev/null +++ b/backend/scripts/test-query.js @@ -0,0 +1,56 @@ +const { Task, sequelize } = require('../models'); +const { Op } = require('sequelize'); + +async function testQuery() { + const whereClause = { + parent_task_id: null, + status: { + [Op.notIn]: [ + Task.STATUS.DONE, + Task.STATUS.ARCHIVED, + 'done', + 'archived', + ], + }, + }; + + whereClause[Op.or] = [ + { + [Op.and]: [ + { + [Op.or]: [ + { recurrence_type: 'none' }, + { recurrence_type: null }, + ], + }, + { recurring_parent_id: null }, + ], + }, + { + [Op.and]: [{ recurring_parent_id: { [Op.ne]: null } }], + }, + ]; + + // Log the SQL that will be generated + const query = Task.findAll({ + where: whereClause, + attributes: ['id', 'name', 'recurrence_type', 'recurring_parent_id'], + logging: console.log, + }); + + console.log('\nThis query should:'); + console.log( + '✓ Include: Regular tasks (recurrence_type = null/none, recurring_parent_id = null)' + ); + console.log('✓ Include: Recurring instances (recurring_parent_id != null)'); + console.log( + '✗ Exclude: Recurring parent templates (recurrence_type = daily/weekly/etc, recurring_parent_id = null)' + ); + + await sequelize.close(); +} + +testQuery().catch((err) => { + console.error(err); + process.exit(1); +}); diff --git a/backend/services/recurringTaskService.js b/backend/services/recurringTaskService.js index 650c332..fb75bd2 100644 --- a/backend/services/recurringTaskService.js +++ b/backend/services/recurringTaskService.js @@ -73,8 +73,10 @@ const processRecurringTask = async (task, now, lookAheadDate = null) => { return newTasks; } - if (!task.last_generated_date && task.due_date) { - const originalDueDate = new Date(task.due_date.getTime()); + if (!task.last_generated_date) { + const originalDueDate = task.due_date + ? new Date(task.due_date.getTime()) + : new Date(now.getTime()); if (originalDueDate <= generateUpTo) { const startOfDay = new Date(originalDueDate); diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index 334dc04..9eae867 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -643,37 +643,38 @@ describe('Recurring Tasks API', () => { }); }); - it('should exclude recurring task instances from type=today API response', async () => { + it('should include recurring task instances in type=today API response', async () => { const response = await agent.get('/api/tasks?type=today'); expect(response.status).toBe(200); expect(response.body.tasks).toBeDefined(); - // Should only return regular task + recurring template, not the instance - expect(response.body.tasks.length).toBe(2); + // Should return at least the regular task + recurring instance + // Parent tasks should NOT appear in today view + expect(response.body.tasks.length).toBeGreaterThanOrEqual(2); const taskIds = response.body.tasks.map((t) => t.id); expect(taskIds).toContain(regularTask.id); - expect(taskIds).toContain(parentTask.id); - expect(taskIds).not.toContain(childTask.id); // Instance should be filtered out + expect(taskIds).not.toContain(parentTask.id); // Parent should NOT be included + expect(taskIds).toContain(childTask.id); // Instance should be included }); - it('should preserve original names for recurring tasks in type=today API response', async () => { + it('should preserve original names for recurring task instances in type=today API response', async () => { const response = await agent.get('/api/tasks?type=today'); expect(response.status).toBe(200); expect(response.body.tasks).toBeDefined(); - // Find the recurring task in the response - const recurringTask = response.body.tasks.find( - (t) => t.id === parentTask.id + // Find the recurring task instance in the response + const recurringInstance = response.body.tasks.find( + (t) => t.id === childTask.id ); - expect(recurringTask).toBeDefined(); + expect(recurringInstance).toBeDefined(); - // Should show original name, not "Daily" - expect(recurringTask.name).toBe('Take vitamins'); - expect(recurringTask.original_name).toBe('Take vitamins'); - expect(recurringTask.name).not.toBe('Daily'); + // Instances should show original name, not "Daily" + expect(recurringInstance.name).toBe('Take vitamins'); + expect(recurringInstance.original_name).toBe('Take vitamins'); + expect(recurringInstance.name).not.toBe('Daily'); }); it('should show generic names for non-today API calls (backward compatibility)', async () => { @@ -763,4 +764,99 @@ describe('Recurring Tasks API', () => { }); }); }); + + describe('Recurring tasks in Today view', () => { + it('should show recurring task instances in type=today API response', async () => { + const recurringTaskService = require('../../services/recurringTaskService'); + + // Create a recurring daily task with due date today + const today = new Date(); + today.setHours(12, 0, 0, 0); + + const taskData = { + name: 'Daily standup meeting', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today.toISOString(), + priority: 1, + completion_based: false, + }; + + const createResponse = await agent.post('/api/task').send(taskData); + expect(createResponse.status).toBe(201); + + const recurringTaskId = createResponse.body.id; + + // Generate recurring task instances + await recurringTaskService.generateRecurringTasks(user.id, 2); + + // Verify instances were created + const instances = await Task.findAll({ + where: { + user_id: user.id, + recurring_parent_id: recurringTaskId, + }, + }); + + expect(instances.length).toBeGreaterThan(0); + + // Fetch tasks with type=today + const todayResponse = await agent.get('/api/tasks?type=today'); + expect(todayResponse.status).toBe(200); + + // Find the recurring task instance in the today response + const todayTasks = todayResponse.body.tasks; + + // Check if we have the recurring instance (but not the parent) + const recurringTasksInToday = todayTasks.filter( + (task) => task.recurring_parent_id === recurringTaskId + ); + + // Should find at least one instance (the one due today) + expect(recurringTasksInToday.length).toBeGreaterThan(0); + + // Verify at least one task with this name appears + const taskWithName = todayTasks.find( + (task) => task.name === 'Daily standup meeting' + ); + expect(taskWithName).toBeDefined(); + expect(taskWithName.recurring_parent_id).toBe(recurringTaskId); + }); + + it('should include recurring_parent_uid in serialized task instances', async () => { + const today = new Date(); + const taskResponse = await agent.post('/api/task').send({ + name: 'Recurring parent test', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today.toISOString().split('T')[0], + }); + + expect(taskResponse.status).toBe(201); + const recurringTask = taskResponse.body; + + await agent.post('/api/tasks/generate-recurring'); + + // Find the generated instance + const generatedInstance = await Task.findOne({ + where: { + user_id: user.id, + recurring_parent_id: recurringTask.id, + }, + }); + + expect(generatedInstance).toBeDefined(); + + const response = await agent.get('/api/tasks?type=today'); + expect(response.status).toBe(200); + + const instance = response.body.tasks.find( + (task) => task.recurring_parent_id === recurringTask.id + ); + + expect(instance).toBeDefined(); + expect(instance.recurring_parent_uid).toBeDefined(); + expect(instance.recurring_parent_uid).toBe(recurringTask.uid); + }); + }); }); diff --git a/e2e/tests/task-priority-due-date.spec.ts b/e2e/tests/task-priority-due-date.spec.ts index b2c62e0..7482a64 100644 --- a/e2e/tests/task-priority-due-date.spec.ts +++ b/e2e/tests/task-priority-due-date.spec.ts @@ -70,10 +70,15 @@ test('user can set task priority to high', async ({ page, baseURL }) => { // Wait for dropdown to close await expect(page.locator('[data-testid="priority-dropdown"][data-state="closed"]')).toBeVisible(); + // Verify modal is still open after priority change + await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible(); + // Wait for the save button to be stable after priority change - await page.waitForTimeout(200); - await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible(); - await page.locator('[data-testid="task-save-button"]').click(); + await page.waitForTimeout(500); + const saveButton0 = page.locator('[data-testid="task-save-button"]'); + await expect(saveButton0).toBeAttached({ timeout: 5000 }); + await expect(saveButton0).toBeVisible({ timeout: 5000 }); + await saveButton0.click(); // Wait for saving state then idle state await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible(); @@ -115,10 +120,15 @@ test('user can set task priority to medium and low', async ({ page, baseURL }) = await mediumPriorityOption.click(); await expect(page.locator('[data-testid="priority-dropdown"][data-state="closed"]')).toBeVisible(); + // Verify modal is still open after priority change + await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible(); + // Wait for the save button to be stable after priority change - await page.waitForTimeout(200); - await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible(); - await page.locator('[data-testid="task-save-button"]').click(); + await page.waitForTimeout(500); + const saveButton1 = page.locator('[data-testid="task-save-button"]'); + await expect(saveButton1).toBeAttached({ timeout: 5000 }); + await expect(saveButton1).toBeVisible({ timeout: 5000 }); + await saveButton1.click(); await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible(); await expect(page.locator('[data-testid="task-modal"]')).not.toBeVisible({ timeout: 10000 }); @@ -149,10 +159,15 @@ test('user can set task priority to medium and low', async ({ page, baseURL }) = await lowPriorityOption.click(); await expect(page.locator('[data-testid="priority-dropdown"][data-state="closed"]')).toBeVisible(); + // Verify modal is still open after priority change + await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible(); + // Wait for the save button to be stable after priority change - await page.waitForTimeout(200); - await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible(); - await page.locator('[data-testid="task-save-button"]').click(); + await page.waitForTimeout(500); + const saveButton2 = page.locator('[data-testid="task-save-button"]'); + await expect(saveButton2).toBeAttached({ timeout: 5000 }); + await expect(saveButton2).toBeVisible({ timeout: 5000 }); + await saveButton2.click(); await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible(); await expect(page.locator('[data-testid="task-modal"]')).not.toBeVisible({ timeout: 10000 }); @@ -195,10 +210,15 @@ test('user can set a due date for a task', async ({ page, baseURL }) => { await dayButton.click(); await expect(page.locator('[data-testid="datepicker"][data-state="closed"]')).toBeVisible(); + // Verify modal is still open after date change + await expect(page.locator('[data-testid="task-modal"][data-state="idle"]')).toBeVisible(); + // Wait for the save button to be stable after date change - await page.waitForTimeout(200); - await expect(page.locator('[data-testid="task-save-button"]')).toBeVisible(); - await page.locator('[data-testid="task-save-button"]').click(); + await page.waitForTimeout(500); + const saveButton3 = page.locator('[data-testid="task-save-button"]'); + await expect(saveButton3).toBeAttached({ timeout: 5000 }); + await expect(saveButton3).toBeVisible({ timeout: 5000 }); + await saveButton3.click(); await expect(page.locator('[data-testid="task-modal"][data-state="saving"]')).toBeVisible(); await expect(page.locator('[data-testid="task-modal"]')).not.toBeVisible({ timeout: 10000 }); diff --git a/frontend/components/Task/TaskDetails.tsx b/frontend/components/Task/TaskDetails.tsx index f40526f..b73569c 100644 --- a/frontend/components/Task/TaskDetails.tsx +++ b/frontend/components/Task/TaskDetails.tsx @@ -22,7 +22,6 @@ import { deleteTask, toggleTaskCompletion, fetchTaskByUid, - fetchTaskById, fetchTaskNextIterations, TaskIteration, } from '../../utils/tasksService'; @@ -252,11 +251,11 @@ const TaskDetails: React.FC = () => { // Load parent task for child tasks (recurring instances) useEffect(() => { const loadParentTask = async () => { - if (task?.recurring_parent_id) { + if (task?.recurring_parent_uid) { try { setLoadingParent(true); - const parent = await fetchTaskById( - task.recurring_parent_id + const parent = await fetchTaskByUid( + task.recurring_parent_uid ); setParentTask(parent); } catch (error) { @@ -269,7 +268,7 @@ const TaskDetails: React.FC = () => { }; loadParentTask(); - }, [task?.recurring_parent_id]); + }, [task?.recurring_parent_uid]); const handleEdit = (e?: React.MouseEvent) => { if (e) { diff --git a/frontend/components/Task/TaskModal.tsx b/frontend/components/Task/TaskModal.tsx index ae9c35a..e65c02f 100644 --- a/frontend/components/Task/TaskModal.tsx +++ b/frontend/components/Task/TaskModal.tsx @@ -6,7 +6,7 @@ import DiscardChangesDialog from '../Shared/DiscardChangesDialog'; import { useToast } from '../Shared/ToastContext'; import { Project } from '../../entities/Project'; import { useStore } from '../../store/useStore'; -import { fetchTaskById } from '../../utils/tasksService'; +import { fetchTaskByUid } from '../../utils/tasksService'; import { analyzeTaskName, TaskAnalysis, @@ -100,7 +100,7 @@ const TaskModal: React.FC = ({ const expandedSections = { ...baseSections, subtasks: baseSections.subtasks || autoFocusSubtasks, - recurrence: baseSections.recurrence || !!task.recurring_parent_id, // Auto-expand for child tasks + recurrence: baseSections.recurrence || !!task.recurring_parent_uid, // Auto-expand for child tasks }; const { showSuccessToast, showErrorToast } = useToast(); @@ -179,11 +179,11 @@ const TaskModal: React.FC = ({ // Handle parent task fetching separately useEffect(() => { const fetchParentTask = async () => { - if (task.recurring_parent_id && isOpen) { + if (task.recurring_parent_uid && isOpen) { setParentTaskLoading(true); try { - const parent = await fetchTaskById( - task.recurring_parent_id + const parent = await fetchTaskByUid( + task.recurring_parent_uid ); setParentTask(parent); } catch (error) { @@ -198,7 +198,7 @@ const TaskModal: React.FC = ({ }; fetchParentTask(); - }, [task.recurring_parent_id, isOpen]); + }, [task.recurring_parent_uid, isOpen]); // Fetch task intelligence setting from user profile useEffect(() => { @@ -934,7 +934,7 @@ const TaskModal: React.FC = ({ > {(formData.recurrence_type || - formData.recurring_parent_id) && ( + formData.recurring_parent_uid) && ( )} diff --git a/frontend/entities/Task.ts b/frontend/entities/Task.ts index c494d98..76d38b0 100644 --- a/frontend/entities/Task.ts +++ b/frontend/entities/Task.ts @@ -25,6 +25,7 @@ export interface Task { recurrence_week_of_month?: number; completion_based?: boolean; recurring_parent_id?: number; + recurring_parent_uid?: string; last_generated_date?: string; completed_at: string | null; parent_task_id?: number;