Fix project view due date timezone bug and recurring multi-weekday bug (#836)
This commit is contained in:
parent
5a1f0650ae
commit
45aec304a3
4 changed files with 144 additions and 10 deletions
|
|
@ -42,7 +42,8 @@ const projectsController = {
|
||||||
async getOne(req, res, next) {
|
async getOne(req, res, next) {
|
||||||
try {
|
try {
|
||||||
const uid = extractUidFromSlug(req.params.uidSlug);
|
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);
|
res.json(project);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
next(error);
|
next(error);
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,10 @@ const { NotFoundError, ValidationError } = require('../../shared/errors');
|
||||||
const { validateTagName } = require('../tags/tagsService');
|
const { validateTagName } = require('../tags/tagsService');
|
||||||
const permissionsService = require('../../services/permissionsService');
|
const permissionsService = require('../../services/permissionsService');
|
||||||
const { sortTags } = require('../tasks/core/serializers');
|
const { sortTags } = require('../tasks/core/serializers');
|
||||||
|
const {
|
||||||
|
getSafeTimezone,
|
||||||
|
processDueDateForResponse,
|
||||||
|
} = require('../../utils/timezone-utils');
|
||||||
const { uid: generateUid } = require('../../utils/uid');
|
const { uid: generateUid } = require('../../utils/uid');
|
||||||
const { extractUidFromSlug } = require('../../utils/slug-utils');
|
const { extractUidFromSlug } = require('../../utils/slug-utils');
|
||||||
const { logError } = require('../../services/logService');
|
const { logError } = require('../../services/logService');
|
||||||
|
|
@ -181,7 +185,7 @@ class ProjectsService {
|
||||||
/**
|
/**
|
||||||
* Get project by UID.
|
* Get project by UID.
|
||||||
*/
|
*/
|
||||||
async getByUid(uid) {
|
async getByUid(uid, userTimezone) {
|
||||||
const validatedUid = validateUid(uid);
|
const validatedUid = validateUid(uid);
|
||||||
const project =
|
const project =
|
||||||
await projectsRepository.findByUidWithIncludes(validatedUid);
|
await projectsRepository.findByUidWithIncludes(validatedUid);
|
||||||
|
|
@ -190,6 +194,7 @@ class ProjectsService {
|
||||||
throw new NotFoundError('Project not found');
|
throw new NotFoundError('Project not found');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const safeTimezone = getSafeTimezone(userTimezone);
|
||||||
const projectJson = project.toJSON();
|
const projectJson = project.toJSON();
|
||||||
|
|
||||||
const normalizedTasks = projectJson.Tasks
|
const normalizedTasks = projectJson.Tasks
|
||||||
|
|
@ -201,11 +206,10 @@ class ProjectsService {
|
||||||
...subtask,
|
...subtask,
|
||||||
tags: sortTags(subtask.Tags),
|
tags: sortTags(subtask.Tags),
|
||||||
})),
|
})),
|
||||||
due_date: task.due_date
|
due_date: processDueDateForResponse(
|
||||||
? typeof task.due_date === 'string'
|
task.due_date,
|
||||||
? task.due_date.split('T')[0]
|
safeTimezone
|
||||||
: task.due_date.toISOString().split('T')[0]
|
),
|
||||||
: null,
|
|
||||||
};
|
};
|
||||||
delete normalizedTask.Tags;
|
delete normalizedTask.Tags;
|
||||||
delete normalizedTask.Subtasks;
|
delete normalizedTask.Subtasks;
|
||||||
|
|
|
||||||
|
|
@ -21,7 +21,8 @@ const calculateNextDueDate = (task, fromDate) => {
|
||||||
return calculateWeeklyRecurrence(
|
return calculateWeeklyRecurrence(
|
||||||
startDate,
|
startDate,
|
||||||
task.recurrence_interval || 1,
|
task.recurrence_interval || 1,
|
||||||
task.recurrence_weekday
|
task.recurrence_weekday,
|
||||||
|
task.recurrence_weekdays
|
||||||
);
|
);
|
||||||
|
|
||||||
case 'monthly':
|
case 'monthly':
|
||||||
|
|
@ -56,10 +57,28 @@ const calculateDailyRecurrence = (fromDate, interval) => {
|
||||||
return nextDate;
|
return nextDate;
|
||||||
};
|
};
|
||||||
|
|
||||||
const calculateWeeklyRecurrence = (fromDate, interval, weekday) => {
|
const calculateWeeklyRecurrence = (fromDate, interval, weekday, weekdays) => {
|
||||||
const nextDate = new Date(fromDate);
|
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 currentWeekday = nextDate.getUTCDay();
|
||||||
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;
|
const daysUntilTarget = (weekday - currentWeekday + 7) % 7;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -215,6 +215,116 @@ describe('Recurring Tasks', () => {
|
||||||
);
|
);
|
||||||
expect(nextDate.getUTCDay()).toBe(targetWeekday);
|
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', () => {
|
describe('Monthly Recurrence', () => {
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue