diff --git a/backend/modules/projects/controller.js b/backend/modules/projects/controller.js index eac478a..67bbec2 100644 --- a/backend/modules/projects/controller.js +++ b/backend/modules/projects/controller.js @@ -42,7 +42,8 @@ const projectsController = { async getOne(req, res, next) { try { const uid = extractUidFromSlug(req.params.uidSlug); - const project = await projectsService.getByUid(uid); + const timezone = req.currentUser?.timezone; + const project = await projectsService.getByUid(uid, timezone); res.json(project); } catch (error) { next(error); diff --git a/backend/modules/projects/service.js b/backend/modules/projects/service.js index ffb5602..43c9675 100644 --- a/backend/modules/projects/service.js +++ b/backend/modules/projects/service.js @@ -7,6 +7,10 @@ const { NotFoundError, ValidationError } = require('../../shared/errors'); const { validateTagName } = require('../tags/tagsService'); const permissionsService = require('../../services/permissionsService'); const { sortTags } = require('../tasks/core/serializers'); +const { + getSafeTimezone, + processDueDateForResponse, +} = require('../../utils/timezone-utils'); const { uid: generateUid } = require('../../utils/uid'); const { extractUidFromSlug } = require('../../utils/slug-utils'); const { logError } = require('../../services/logService'); @@ -181,7 +185,7 @@ class ProjectsService { /** * Get project by UID. */ - async getByUid(uid) { + async getByUid(uid, userTimezone) { const validatedUid = validateUid(uid); const project = await projectsRepository.findByUidWithIncludes(validatedUid); @@ -190,6 +194,7 @@ class ProjectsService { throw new NotFoundError('Project not found'); } + const safeTimezone = getSafeTimezone(userTimezone); const projectJson = project.toJSON(); const normalizedTasks = projectJson.Tasks @@ -201,11 +206,10 @@ class ProjectsService { ...subtask, tags: sortTags(subtask.Tags), })), - due_date: task.due_date - ? typeof task.due_date === 'string' - ? task.due_date.split('T')[0] - : task.due_date.toISOString().split('T')[0] - : null, + due_date: processDueDateForResponse( + task.due_date, + safeTimezone + ), }; delete normalizedTask.Tags; delete normalizedTask.Subtasks; diff --git a/backend/modules/tasks/recurringTaskService.js b/backend/modules/tasks/recurringTaskService.js index ec88861..74f4e55 100644 --- a/backend/modules/tasks/recurringTaskService.js +++ b/backend/modules/tasks/recurringTaskService.js @@ -21,7 +21,8 @@ const calculateNextDueDate = (task, fromDate) => { return calculateWeeklyRecurrence( startDate, task.recurrence_interval || 1, - task.recurrence_weekday + task.recurrence_weekday, + task.recurrence_weekdays ); case 'monthly': @@ -56,10 +57,28 @@ const calculateDailyRecurrence = (fromDate, interval) => { return nextDate; }; -const calculateWeeklyRecurrence = (fromDate, interval, weekday) => { +const calculateWeeklyRecurrence = (fromDate, interval, weekday, weekdays) => { const nextDate = new Date(fromDate); - if (weekday !== null && weekday !== undefined) { + // Handle multiple weekdays (e.g. Tuesday AND Thursday) + const parsedWeekdays = weekdays + ? Array.isArray(weekdays) + ? weekdays + : JSON.parse(weekdays) + : null; + + if (parsedWeekdays && parsedWeekdays.length > 0) { + // Find the next matching weekday from tomorrow onward + for (let daysAhead = 1; daysAhead <= 7; daysAhead++) { + const testDate = new Date(nextDate); + testDate.setUTCDate(testDate.getUTCDate() + daysAhead); + if (parsedWeekdays.includes(testDate.getUTCDay())) { + return testDate; + } + } + // Fallback: advance by interval weeks + nextDate.setUTCDate(nextDate.getUTCDate() + interval * 7); + } else if (weekday !== null && weekday !== undefined) { const currentWeekday = nextDate.getUTCDay(); const daysUntilTarget = (weekday - currentWeekday + 7) % 7; diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index 09020f3..acd5d22 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -215,6 +215,116 @@ describe('Recurring Tasks', () => { ); expect(nextDate.getUTCDay()).toBe(targetWeekday); }); + + it('should advance to next selected weekday with multiple weekdays (Issue #715)', async () => { + // Use a fixed Tuesday: 2026-02-10 + const tuesday = new Date(Date.UTC(2026, 1, 10, 0, 0, 0, 0)); + expect(tuesday.getUTCDay()).toBe(2); // Sanity: Tuesday + + const task = await Task.create({ + name: 'Tue/Thu Task', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekdays: [2, 4], // Tuesday, Thursday + due_date: tuesday, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Completing on Tuesday should advance to Thursday (2 days), not next Tuesday (7 days) + const nextDate = calculateNextDueDate(task, tuesday); + expect(nextDate.getUTCDay()).toBe(4); // Thursday + expect(nextDate.toISOString().split('T')[0]).toBe('2026-02-12'); + }); + + it('should wrap around to next week when completing on last selected weekday', async () => { + // Use a fixed Thursday: 2026-02-12 + const thursday = new Date(Date.UTC(2026, 1, 12, 0, 0, 0, 0)); + expect(thursday.getUTCDay()).toBe(4); // Sanity: Thursday + + const task = await Task.create({ + name: 'Tue/Thu Task', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekdays: [2, 4], // Tuesday, Thursday + due_date: thursday, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Completing on Thursday should wrap to next Tuesday (5 days) + const nextDate = calculateNextDueDate(task, thursday); + expect(nextDate.getUTCDay()).toBe(2); // Tuesday + expect(nextDate.toISOString().split('T')[0]).toBe('2026-02-17'); + }); + + it('should advance due date correctly on completion with multiple weekdays via API', async () => { + // Use a fixed Tuesday: 2026-02-10 + const tuesday = new Date(Date.UTC(2026, 1, 10, 0, 0, 0, 0)); + + const task = await Task.create({ + name: 'Tue/Thu Via API', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekdays: [2, 4], // Tuesday, Thursday + due_date: tuesday, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Complete the task on Tuesday + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + // Should advance to Thursday, not next Tuesday + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + const newDueDate = new Date(task.due_date); + expect(newDueDate.getUTCDay()).toBe(4); // Thursday + + // Complete again on Thursday + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + // Should wrap to next Tuesday + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + const nextDueDate = new Date(task.due_date); + expect(nextDueDate.getUTCDay()).toBe(2); // Tuesday + }); + + it('should handle three weekdays (Mon/Wed/Fri)', async () => { + // Use a fixed Monday: 2026-02-09 + const monday = new Date(Date.UTC(2026, 1, 9, 0, 0, 0, 0)); + expect(monday.getUTCDay()).toBe(1); // Sanity: Monday + + const task = await Task.create({ + name: 'MWF Task', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekdays: [1, 3, 5], // Mon, Wed, Fri + due_date: monday, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Monday -> Wednesday + const next1 = calculateNextDueDate(task, monday); + expect(next1.getUTCDay()).toBe(3); + expect(next1.toISOString().split('T')[0]).toBe('2026-02-11'); + + // Wednesday -> Friday + const next2 = calculateNextDueDate(task, next1); + expect(next2.getUTCDay()).toBe(5); + expect(next2.toISOString().split('T')[0]).toBe('2026-02-13'); + + // Friday -> next Monday + const next3 = calculateNextDueDate(task, next2); + expect(next3.getUTCDay()).toBe(1); + expect(next3.toISOString().split('T')[0]).toBe('2026-02-16'); + }); }); describe('Monthly Recurrence', () => {