Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion backend/modules/projects/controller.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
16 changes: 10 additions & 6 deletions backend/modules/projects/service.js
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down Expand Up @@ -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);
Expand All @@ -190,6 +194,7 @@ class ProjectsService {
throw new NotFoundError('Project not found');
}

const safeTimezone = getSafeTimezone(userTimezone);
const projectJson = project.toJSON();

const normalizedTasks = projectJson.Tasks
Expand All @@ -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;
Expand Down
25 changes: 22 additions & 3 deletions backend/modules/tasks/recurringTaskService.js
Original file line number Diff line number Diff line change
Expand Up @@ -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':
Expand Down Expand Up @@ -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;

Expand Down
110 changes: 110 additions & 0 deletions backend/tests/integration/recurring-tasks.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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', () => {
Expand Down