Fix recurring structure (#646)

* Refactor recurring

* fixup! Refactor recurring

* Add after completion tests

* fixup! Add after completion tests

* fixup! fixup! Add after completion tests
This commit is contained in:
Chris 2025-12-04 13:29:37 +02:00 committed by GitHub
parent e75a6e290e
commit cd6b810b08
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
32 changed files with 1957 additions and 3552 deletions

View file

@ -1,95 +1,82 @@
'use strict';
const {
safeAddColumns,
safeAddIndex,
safeRemoveColumn,
} = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
try {
const tableInfo = await queryInterface.describeTable('tasks');
const columnsToAdd = [
{
name: 'recurrence_weekday',
definition: {
type: Sequelize.INTEGER,
allowNull: true,
comment:
'Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) for weekly recurrence',
},
await safeAddColumns(queryInterface, 'tasks', [
{
name: 'recurrence_weekday',
definition: {
type: Sequelize.INTEGER,
allowNull: true,
comment:
'Day of week (0=Sunday, 1=Monday, ..., 6=Saturday) for weekly recurrence',
},
{
name: 'recurrence_month_day',
definition: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Day of month (1-31) for monthly recurrence',
},
},
{
name: 'recurrence_month_day',
definition: {
type: Sequelize.INTEGER,
allowNull: true,
comment: 'Day of month (1-31) for monthly recurrence',
},
{
name: 'recurrence_week_of_month',
definition: {
type: Sequelize.INTEGER,
allowNull: true,
comment:
'Week of month (1-4, -1=last) for monthly recurrence',
},
},
{
name: 'recurrence_week_of_month',
definition: {
type: Sequelize.INTEGER,
allowNull: true,
comment:
'Week of month (1-4, -1=last) for monthly recurrence',
},
{
name: 'completion_based',
definition: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment:
'Whether recurrence is based on completion date (true) or due date (false)',
},
},
{
name: 'completion_based',
definition: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: false,
comment:
'Whether recurrence is based on completion date (true) or due date (false)',
},
];
},
]);
for (const column of columnsToAdd) {
if (!(column.name in tableInfo)) {
await queryInterface.addColumn(
'tasks',
column.name,
column.definition
);
}
}
try {
const indexes = await queryInterface.showIndex('tasks');
const indexExists = indexes.some(
(index) => index.name === 'idx_tasks_recurrence_lookup'
);
if (!indexExists) {
await queryInterface.addIndex(
'tasks',
['recurrence_type', 'last_generated_date'],
{
name: 'idx_tasks_recurrence_lookup',
}
);
}
} catch (indexError) {
console.log(
'Could not check or add index for recurrence lookup:',
indexError.message
);
}
} catch (error) {
console.log('Migration error:', error.message);
throw error;
}
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('tasks', 'recurrence_weekday');
await queryInterface.removeColumn('tasks', 'recurrence_month_day');
await queryInterface.removeColumn('tasks', 'recurrence_week_of_month');
await queryInterface.removeColumn('tasks', 'completion_based');
await queryInterface.removeIndex(
await safeAddIndex(
queryInterface,
'tasks',
'idx_tasks_recurrence_lookup'
['recurrence_type', 'last_generated_date'],
{
name: 'idx_tasks_recurrence_lookup',
}
);
},
async down(queryInterface) {
await safeRemoveColumn(queryInterface, 'tasks', 'recurrence_weekday');
await safeRemoveColumn(queryInterface, 'tasks', 'recurrence_month_day');
await safeRemoveColumn(
queryInterface,
'tasks',
'recurrence_week_of_month'
);
await safeRemoveColumn(queryInterface, 'tasks', 'completion_based');
try {
await queryInterface.removeIndex(
'tasks',
'idx_tasks_recurrence_lookup'
);
} catch (error) {
console.log(
'Could not remove index idx_tasks_recurrence_lookup:',
error.message
);
}
},
};

View file

@ -1,12 +1,17 @@
'use strict';
const {
safeAddColumns,
safeAddIndex,
safeRemoveColumn,
} = require('../utils/migration-utils');
module.exports = {
up: async (queryInterface, Sequelize) => {
try {
const tableInfo = await queryInterface.describeTable('tasks');
if (!('recurring_parent_id' in tableInfo)) {
await queryInterface.addColumn('tasks', 'recurring_parent_id', {
await safeAddColumns(queryInterface, 'tasks', [
{
name: 'recurring_parent_id',
definition: {
type: Sequelize.INTEGER,
allowNull: true,
references: {
@ -15,36 +20,22 @@ module.exports = {
},
onUpdate: 'CASCADE',
onDelete: 'SET NULL',
});
}
},
},
]);
try {
const indexes = await queryInterface.showIndex('tasks');
const indexExists = indexes.some((index) =>
index.fields.some(
(field) => field.attribute === 'recurring_parent_id'
)
);
if (!indexExists) {
await queryInterface.addIndex('tasks', [
'recurring_parent_id',
]);
}
} catch (indexError) {
console.log(
'Could not check or add index for recurring_parent_id:',
indexError.message
);
}
} catch (error) {
console.log('Migration error:', error.message);
throw error;
}
await safeAddIndex(queryInterface, 'tasks', ['recurring_parent_id']);
},
down: async (queryInterface) => {
await queryInterface.removeIndex('tasks', ['recurring_parent_id']);
await queryInterface.removeColumn('tasks', 'recurring_parent_id');
try {
await queryInterface.removeIndex('tasks', ['recurring_parent_id']);
} catch (error) {
console.log(
'Could not remove index recurring_parent_id:',
error.message
);
}
await safeRemoveColumn(queryInterface, 'tasks', 'recurring_parent_id');
},
};

View file

@ -1,50 +1,40 @@
'use strict';
const {
safeAddColumns,
safeRemoveColumn,
} = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
try {
const tableInfo = await queryInterface.describeTable('users');
const columnsToAdd = [
{
name: 'productivity_assistant_enabled',
definition: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
await safeAddColumns(queryInterface, 'users', [
{
name: 'productivity_assistant_enabled',
definition: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
{
name: 'next_task_suggestion_enabled',
definition: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
},
{
name: 'next_task_suggestion_enabled',
definition: {
type: Sequelize.BOOLEAN,
allowNull: false,
defaultValue: true,
},
];
for (const column of columnsToAdd) {
if (!(column.name in tableInfo)) {
await queryInterface.addColumn(
'users',
column.name,
column.definition
);
}
}
} catch (error) {
console.log('Migration error:', error.message);
throw error;
}
},
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn(
async down(queryInterface) {
await safeRemoveColumn(
queryInterface,
'users',
'productivity_assistant_enabled'
);
await queryInterface.removeColumn(
await safeRemoveColumn(
queryInterface,
'users',
'next_task_suggestion_enabled'
);

View file

@ -1,13 +1,16 @@
'use strict';
const {
safeAddColumns,
safeRemoveColumn,
} = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
try {
const tableInfo = await queryInterface.describeTable('users');
// Check if today_settings column already exists
if (!('today_settings' in tableInfo)) {
await queryInterface.addColumn('users', 'today_settings', {
await safeAddColumns(queryInterface, 'users', [
{
name: 'today_settings',
definition: {
type: Sequelize.JSON,
allowNull: true,
defaultValue: {
@ -20,15 +23,12 @@ module.exports = {
showProgressBar: true,
showDailyQuote: true,
},
});
}
} catch (error) {
console.log('Migration error:', error.message);
throw error;
}
},
},
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('users', 'today_settings');
async down(queryInterface) {
await safeRemoveColumn(queryInterface, 'users', 'today_settings');
},
};

View file

@ -1,39 +1,38 @@
'use strict';
const {
safeAddColumns,
safeRemoveColumn,
} = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
try {
const tableInfo = await queryInterface.describeTable('projects');
// Check if task_show_completed column already exists
if (!('task_show_completed' in tableInfo)) {
await queryInterface.addColumn(
'projects',
'task_show_completed',
{
type: Sequelize.BOOLEAN,
allowNull: true,
defaultValue: false,
}
);
}
// Check if task_sort_order column already exists
if (!('task_sort_order' in tableInfo)) {
await queryInterface.addColumn('projects', 'task_sort_order', {
await safeAddColumns(queryInterface, 'projects', [
{
name: 'task_show_completed',
definition: {
type: Sequelize.BOOLEAN,
allowNull: true,
defaultValue: false,
},
},
{
name: 'task_sort_order',
definition: {
type: Sequelize.STRING,
allowNull: true,
defaultValue: 'created_at:desc',
});
}
} catch (error) {
console.log('Migration error:', error.message);
throw error;
}
},
},
]);
},
async down(queryInterface, Sequelize) {
await queryInterface.removeColumn('projects', 'task_show_completed');
await queryInterface.removeColumn('projects', 'task_sort_order');
async down(queryInterface) {
await safeRemoveColumn(
queryInterface,
'projects',
'task_show_completed'
);
await safeRemoveColumn(queryInterface, 'projects', 'task_sort_order');
},
};

View file

@ -0,0 +1,48 @@
'use strict';
const { safeCreateTable, safeAddIndex } = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
await safeCreateTable(queryInterface, 'recurring_completions', {
id: {
type: Sequelize.INTEGER,
primaryKey: true,
autoIncrement: true,
},
task_id: {
type: Sequelize.INTEGER,
allowNull: false,
references: { model: 'tasks', key: 'id' },
onDelete: 'CASCADE',
},
completed_at: {
type: Sequelize.DATE,
allowNull: false,
},
original_due_date: {
type: Sequelize.DATE,
allowNull: false,
},
skipped: {
type: Sequelize.BOOLEAN,
defaultValue: false,
},
created_at: {
type: Sequelize.DATE,
defaultValue: Sequelize.literal('CURRENT_TIMESTAMP'),
},
});
await safeAddIndex(queryInterface, 'recurring_completions', [
'task_id',
]);
await safeAddIndex(queryInterface, 'recurring_completions', [
'completed_at',
]);
},
async down(queryInterface) {
await queryInterface.dropTable('recurring_completions');
},
};

View file

@ -0,0 +1,85 @@
'use strict';
const {
safeRemoveColumn,
safeAddColumns,
} = require('../utils/migration-utils');
module.exports = {
async up(queryInterface, Sequelize) {
const templates = await queryInterface.sequelize.query(
`SELECT * FROM tasks WHERE recurrence_type != 'none' AND recurring_parent_id IS NULL`,
{ type: Sequelize.QueryTypes.SELECT }
);
for (const template of templates) {
const completedInstances = await queryInterface.sequelize.query(
`SELECT * FROM tasks
WHERE recurring_parent_id = :templateId
AND status IN (2, 3)
AND completed_at IS NOT NULL
ORDER BY completed_at ASC`,
{
replacements: { templateId: template.id },
type: Sequelize.QueryTypes.SELECT,
}
);
for (const instance of completedInstances) {
await queryInterface.bulkInsert('recurring_completions', [
{
task_id: template.id,
completed_at: instance.completed_at,
original_due_date: instance.due_date,
skipped: false,
created_at: instance.completed_at,
},
]);
}
const nextInstance = await queryInterface.sequelize.query(
`SELECT * FROM tasks
WHERE recurring_parent_id = :templateId
AND status NOT IN (2, 3)
AND due_date >= date('now')
ORDER BY due_date ASC
LIMIT 1`,
{
replacements: { templateId: template.id },
type: Sequelize.QueryTypes.SELECT,
}
);
if (nextInstance.length > 0) {
await queryInterface.sequelize.query(
`UPDATE tasks SET due_date = :nextDue WHERE id = :templateId`,
{
replacements: {
nextDue: nextInstance[0].due_date,
templateId: template.id,
},
}
);
}
await queryInterface.sequelize.query(
`DELETE FROM tasks WHERE recurring_parent_id = :templateId`,
{ replacements: { templateId: template.id } }
);
}
await safeRemoveColumn(queryInterface, 'tasks', 'last_generated_date');
},
async down(queryInterface, Sequelize) {
await safeAddColumns(queryInterface, 'tasks', [
{
name: 'last_generated_date',
definition: {
type: Sequelize.DATE,
allowNull: true,
},
},
]);
},
};

View file

@ -3,7 +3,6 @@ const path = require('path');
const { getConfig } = require('../config/config');
const config = getConfig();
// Database configuration
let dbConfig;
dbConfig = {
@ -20,7 +19,6 @@ dbConfig = {
const sequelize = new Sequelize(dbConfig);
// Import models
const User = require('./user')(sequelize);
const Area = require('./area')(sequelize);
const Project = require('./project')(sequelize);
@ -36,8 +34,8 @@ const View = require('./view')(sequelize);
const ApiToken = require('./api_token')(sequelize);
const Setting = require('./setting')(sequelize);
const Notification = require('./notification')(sequelize);
const RecurringCompletion = require('./recurringCompletion')(sequelize);
// Define associations
User.hasMany(Area, { foreignKey: 'user_id' });
Area.belongsTo(User, { foreignKey: 'user_id' });
@ -62,13 +60,11 @@ Project.hasMany(Note, { foreignKey: 'project_id' });
User.hasMany(InboxItem, { foreignKey: 'user_id' });
InboxItem.belongsTo(User, { foreignKey: 'user_id' });
// TaskEvent associations
User.hasMany(TaskEvent, { foreignKey: 'user_id', as: 'TaskEvents' });
TaskEvent.belongsTo(User, { foreignKey: 'user_id', as: 'User' });
Task.hasMany(TaskEvent, { foreignKey: 'task_id', as: 'TaskEvents' });
TaskEvent.belongsTo(Task, { foreignKey: 'task_id', as: 'Task' });
// Task self-referencing associations for subtasks
Task.belongsTo(Task, {
as: 'ParentTask',
foreignKey: 'parent_task_id',
@ -78,7 +74,6 @@ Task.hasMany(Task, {
foreignKey: 'parent_task_id',
});
// Task self-referencing associations for recurring tasks
Task.belongsTo(Task, {
as: 'RecurringParent',
foreignKey: 'recurring_parent_id',
@ -88,7 +83,15 @@ Task.hasMany(Task, {
foreignKey: 'recurring_parent_id',
});
// Many-to-many associations
Task.hasMany(RecurringCompletion, {
as: 'Completions',
foreignKey: 'task_id',
});
RecurringCompletion.belongsTo(Task, {
foreignKey: 'task_id',
as: 'Task',
});
Task.belongsToMany(Tag, {
through: 'tasks_tags',
foreignKey: 'task_id',
@ -122,7 +125,6 @@ Tag.belongsToMany(Project, {
otherKey: 'project_id',
});
// Roles and permissions associations
User.hasOne(Role, { foreignKey: 'user_id' });
Role.belongsTo(User, { foreignKey: 'user_id' });
@ -131,21 +133,15 @@ Permission.belongsTo(User, {
foreignKey: 'granted_by_user_id',
as: 'GrantedBy',
});
// Optional backrefs if needed later:
// User.hasMany(Permission, { foreignKey: 'user_id', as: 'Permissions' });
// Actions relations (optional aliases)
Action.belongsTo(User, { foreignKey: 'actor_user_id', as: 'Actor' });
Action.belongsTo(User, { foreignKey: 'target_user_id', as: 'Target' });
// View associations
User.hasMany(View, { foreignKey: 'user_id' });
View.belongsTo(User, { foreignKey: 'user_id' });
User.hasMany(ApiToken, { foreignKey: 'user_id', as: 'apiTokens' });
ApiToken.belongsTo(User, { foreignKey: 'user_id', as: 'user' });
// Notification associations
User.hasMany(Notification, { foreignKey: 'user_id', as: 'Notifications' });
Notification.belongsTo(User, { foreignKey: 'user_id', as: 'User' });
@ -166,4 +162,5 @@ module.exports = {
ApiToken,
Setting,
Notification,
RecurringCompletion,
};

View file

@ -0,0 +1,47 @@
const { DataTypes } = require('sequelize');
module.exports = (sequelize) => {
const RecurringCompletion = sequelize.define(
'RecurringCompletion',
{
id: {
type: DataTypes.INTEGER,
primaryKey: true,
autoIncrement: true,
},
task_id: {
type: DataTypes.INTEGER,
allowNull: false,
references: {
model: 'tasks',
key: 'id',
},
},
completed_at: {
type: DataTypes.DATE,
allowNull: false,
},
original_due_date: {
type: DataTypes.DATE,
allowNull: false,
},
skipped: {
type: DataTypes.BOOLEAN,
defaultValue: false,
},
},
{
tableName: 'recurring_completions',
timestamps: false,
}
);
RecurringCompletion.associate = function (models) {
RecurringCompletion.belongsTo(models.Task, {
foreignKey: 'task_id',
as: 'Task',
});
};
return RecurringCompletion;
};

View file

@ -78,10 +78,6 @@ module.exports = (sequelize) => {
type: DataTypes.DATE,
allowNull: true,
},
last_generated_date: {
type: DataTypes.DATE,
allowNull: true,
},
recurrence_weekday: {
type: DataTypes.INTEGER,
allowNull: true,
@ -179,9 +175,6 @@ module.exports = (sequelize) => {
{
fields: ['recurrence_type'],
},
{
fields: ['last_generated_date'],
},
{
fields: ['parent_task_id'],
},
@ -193,9 +186,7 @@ module.exports = (sequelize) => {
}
);
// Define associations
Task.associate = function (models) {
// Self-referencing association for recurring tasks
Task.belongsTo(models.Task, {
as: 'RecurringParent',
foreignKey: 'recurring_parent_id',
@ -206,7 +197,6 @@ module.exports = (sequelize) => {
foreignKey: 'recurring_parent_id',
});
// Self-referencing association for subtasks
Task.belongsTo(models.Task, {
as: 'ParentTask',
foreignKey: 'parent_task_id',
@ -218,7 +208,6 @@ module.exports = (sequelize) => {
});
};
// Define enum constants
Task.PRIORITY = {
LOW: 0,
MEDIUM: 1,
@ -242,7 +231,6 @@ module.exports = (sequelize) => {
MONTHLY_LAST_DAY: 'monthly_last_day',
};
// priority and status
const getPriorityName = (priorityValue) => {
const priorities = ['low', 'medium', 'high'];
return priorities[priorityValue] || 'low';
@ -277,7 +265,6 @@ module.exports = (sequelize) => {
return statuses[statusName] !== undefined ? statuses[statusName] : 0;
};
// Attach utility functions to model
Task.getPriorityName = getPriorityName;
Task.getStatusName = getStatusName;
Task.getPriorityValue = getPriorityValue;

View file

@ -6,15 +6,30 @@ const {
} = require('../../../utils/timezone-utils');
function buildTaskAttributes(body, userId, timezone, isUpdate = false) {
const recurrenceType = body.recurrence_type || 'none';
const isRecurring = recurrenceType && recurrenceType !== 'none';
let dueDate = body.due_date;
if (
isRecurring &&
(dueDate === undefined || dueDate === null || dueDate === '')
) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
dueDate = `${year}-${month}-${day}`;
}
const attrs = {
name: body.name?.trim(),
priority: parsePriority(body.priority),
due_date: processDueDateForStorage(body.due_date, timezone),
due_date: processDueDateForStorage(dueDate, timezone),
defer_until: processDeferUntilForStorage(body.defer_until, timezone),
status: parseStatus(body.status),
note: body.note,
today: body.today !== undefined ? body.today : false,
recurrence_type: body.recurrence_type || 'none',
recurrence_type: recurrenceType,
recurrence_interval: body.recurrence_interval || null,
recurrence_end_date: body.recurrence_end_date || null,
recurrence_weekday:
@ -44,6 +59,17 @@ function buildTaskAttributes(body, userId, timezone, isUpdate = false) {
}
function buildUpdateAttributes(body, task, timezone) {
const recurrenceType =
body.recurrence_type !== undefined
? body.recurrence_type
: task.recurrence_type;
const isRecurring = recurrenceType && recurrenceType !== 'none';
const isAddingRecurrence =
body.recurrence_type !== undefined &&
body.recurrence_type !== 'none' &&
(task.recurrence_type === 'none' || !task.recurrence_type);
const attrs = {
name: body.name,
priority:
@ -56,10 +82,7 @@ function buildUpdateAttributes(body, task, timezone) {
: Task.STATUS.NOT_STARTED,
note: body.note,
today: body.today !== undefined ? body.today : task.today,
recurrence_type:
body.recurrence_type !== undefined
? body.recurrence_type
: task.recurrence_type,
recurrence_type: recurrenceType,
recurrence_interval:
body.recurrence_interval !== undefined
? body.recurrence_interval
@ -90,10 +113,28 @@ function buildUpdateAttributes(body, task, timezone) {
: task.completion_based,
};
// Only process dates if they are present in the body
if (body.due_date !== undefined) {
attrs.due_date = processDueDateForStorage(body.due_date, timezone);
if (isRecurring && (body.due_date === null || body.due_date === '')) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
attrs.due_date = processDueDateForStorage(
`${year}-${month}-${day}`,
timezone
);
} else {
attrs.due_date = processDueDateForStorage(body.due_date, timezone);
}
} else if (isAddingRecurrence && (!task.due_date || task.due_date === '')) {
const now = new Date();
const year = now.getFullYear();
const month = String(now.getMonth() + 1).padStart(2, '0');
const day = String(now.getDate()).padStart(2, '0');
const dueDate = `${year}-${month}-${day}`;
attrs.due_date = processDueDateForStorage(dueDate, timezone);
}
if (body.defer_until !== undefined) {
attrs.defer_until = processDeferUntilForStorage(
body.defer_until,

View file

@ -18,11 +18,13 @@ async function serializeTask(
if (!task) {
throw new Error('Task is null or undefined');
}
const taskJson = task.toJSON();
const taskJson = task.toJSON ? task.toJSON() : task;
const todayMoveCount = moveCountMap
? moveCountMap[task.id] || 0
: await getTaskTodayMoveCount(task.id);
const todayMoveCount = taskJson.is_virtual_occurrence
? 0
: moveCountMap
? moveCountMap[task.id] || 0
: await getTaskTodayMoveCount(task.id);
const safeTimezone = getSafeTimezone(userTimezone);

View file

@ -1,7 +1,12 @@
const express = require('express');
const router = express.Router();
const { Task, TaskEvent, sequelize } = require('../../models');
const {
Task,
TaskEvent,
RecurringCompletion,
sequelize,
} = require('../../models');
const taskRepository = require('../../repositories/TaskRepository');
const {
resetQueryCounter,
@ -10,8 +15,9 @@ const {
} = require('../../middleware/queryLogger');
const {
generateRecurringTasks,
handleTaskCompletion,
calculateNextDueDate,
calculateVirtualOccurrences,
shouldGenerateNextTask,
} = require('../../services/recurringTaskService');
const { logError } = require('../../services/logService');
const { logEvent } = require('../../services/taskEventService');
@ -70,6 +76,93 @@ if (process.env.NODE_ENV === 'development') {
enableQueryLogging();
}
function expandRecurringTasks(tasks, maxDays = 7, statusFilter = null) {
const expandedTasks = [];
const now = new Date();
now.setHours(0, 0, 0, 0);
tasks.forEach((task) => {
const isRecurring =
task.recurrence_type &&
task.recurrence_type !== 'none' &&
!task.recurring_parent_id;
if (!isRecurring) {
expandedTasks.push(task);
return;
}
console.log('[DEBUG] Processing recurring task:', {
id: task.id,
name: task.name,
recurrence_type: task.recurrence_type,
due_date: task.due_date,
status: task.status,
completed_at: task.completed_at,
has_due_date: !!task.due_date,
statusFilter: statusFilter,
});
if (
(statusFilter === 'completed' || statusFilter === 'done') &&
(task.status === 2 || task.status === 'done')
) {
console.log(
'[DEBUG] Task is completed and filter is completed, showing actual task'
);
expandedTasks.push(task);
return;
}
let startFrom = task.due_date ? new Date(task.due_date) : now;
if (task.status === 2 || task.status === 'done') {
const baseDate =
task.completion_based && task.completed_at
? new Date(task.completed_at)
: new Date(task.due_date || now);
const nextDate = calculateNextDueDate(task, baseDate);
startFrom = nextDate || now;
console.log(
'[DEBUG] Task is completed, starting from next occurrence:',
startFrom
);
} else if (startFrom < now) {
let nextDate = startFrom;
let iterations = 0;
const MAX_ITERATIONS = 100;
while (nextDate && nextDate < now && iterations < MAX_ITERATIONS) {
nextDate = calculateNextDueDate(task, nextDate);
iterations++;
}
startFrom = nextDate || now;
}
console.log('[DEBUG] Starting from date:', startFrom);
const occurrences = calculateVirtualOccurrences(
task,
maxDays,
startFrom
);
console.log('[DEBUG] Generated occurrences:', occurrences.length);
occurrences.forEach((occurrence, index) => {
const virtualTask = {
...(task.toJSON ? task.toJSON() : task),
due_date: occurrence.due_date,
is_virtual_occurrence: true,
occurrence_index: index,
virtual_id: `${task.id}_occurrence_${index}`,
};
expandedTasks.push(virtualTask);
});
});
return expandedTasks;
}
router.get('/tasks', async (req, res) => {
const startTime = Date.now();
resetQueryCounter();
@ -90,12 +183,32 @@ router.get('/tasks', async (req, res) => {
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 === 'upcoming' && groupBy === 'day') {
console.log('[DEBUG] Expanding recurring tasks for /upcoming');
console.log('[DEBUG] Total tasks before expansion:', tasks.length);
console.log(
'[DEBUG] Recurring tasks:',
tasks
.filter(
(t) => t.recurrence_type && t.recurrence_type !== 'none'
)
.map((t) => ({
id: t.id,
name: t.name,
recurrence_type: t.recurrence_type,
due_date: t.due_date,
recurring_parent_id: t.recurring_parent_id,
}))
);
const days = maxDays ? parseInt(maxDays, 10) : 7;
tasks = expandRecurringTasks(tasks, days, req.query.status);
console.log('[DEBUG] Total tasks after expansion:', tasks.length);
}
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 &&
@ -104,12 +217,10 @@ router.get('/tasks', async (req, res) => {
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 ||
@ -119,7 +230,6 @@ router.get('/tasks', async (req, res) => {
);
}
// Pagination support
const hasPagination =
limitParam !== undefined || offsetParam !== undefined;
const totalCount = tasks.length;
@ -168,7 +278,6 @@ router.get('/tasks', async (req, res) => {
serializationOptions
);
// Add pagination metadata if pagination was requested
if (hasPagination) {
const limit = parseInt(limitParam, 10) || 20;
const offset = parseInt(offsetParam, 10) || 0;
@ -223,7 +332,6 @@ router.post('/task', async (req, res) => {
timezone
);
// Validate defer_until vs due_date
try {
validateDeferUntilAndDueDate(
taskAttributes.defer_until,
@ -410,8 +518,6 @@ router.patch('/task/:uid', requireTaskWriteAccess, async (req, res) => {
const timezone = getSafeTimezone(req.currentUser.timezone);
const taskAttributes = buildUpdateAttributes(req.body, task, timezone);
// Validate defer_until vs due_date
// Use the new values if provided, otherwise use existing task values
try {
const finalDeferUntil =
taskAttributes.defer_until !== undefined
@ -485,9 +591,86 @@ router.patch('/task/:uid', requireTaskWriteAccess, async (req, res) => {
req.body
);
const resolveFinalValue = (field) =>
taskAttributes[field] !== undefined
? taskAttributes[field]
: task[field];
const finalRecurrenceType = resolveFinalValue('recurrence_type');
const finalCompletionBased = resolveFinalValue('completion_based');
const finalDueDateBeforeAdvance =
taskAttributes.due_date !== undefined
? taskAttributes.due_date
: task.due_date;
let recurringCompletionPayload = null;
let recurrenceAdvanceInfo = null;
if (
status !== undefined &&
(taskAttributes.status === Task.STATUS.DONE ||
taskAttributes.status === 'done') &&
finalRecurrenceType &&
finalRecurrenceType !== 'none' &&
!task.recurring_parent_id
) {
const completedAt = new Date();
const hasOriginalDueDate =
finalDueDateBeforeAdvance !== undefined &&
finalDueDateBeforeAdvance !== null &&
finalDueDateBeforeAdvance !== '';
const originalDueDate = hasOriginalDueDate
? new Date(finalDueDateBeforeAdvance)
: new Date(completedAt);
const recurrenceContext = {
...(typeof task.get === 'function'
? task.get({ plain: true })
: task),
recurrence_type: finalRecurrenceType,
recurrence_interval: resolveFinalValue('recurrence_interval'),
recurrence_end_date: resolveFinalValue('recurrence_end_date'),
recurrence_weekday: resolveFinalValue('recurrence_weekday'),
recurrence_weekdays: resolveFinalValue('recurrence_weekdays'),
recurrence_month_day: resolveFinalValue('recurrence_month_day'),
recurrence_week_of_month: resolveFinalValue(
'recurrence_week_of_month'
),
completion_based: finalCompletionBased,
due_date: originalDueDate,
};
const baseDate = finalCompletionBased
? completedAt
: new Date(originalDueDate);
const nextDueDate = calculateNextDueDate(
recurrenceContext,
baseDate
);
recurringCompletionPayload = {
task_id: task.id,
completed_at: completedAt,
original_due_date: new Date(originalDueDate),
skipped: false,
};
recurrenceAdvanceInfo = {
originalDueDate: new Date(originalDueDate),
completedAt,
nextDueDate,
};
if (
nextDueDate &&
shouldGenerateNextTask(recurrenceContext, nextDueDate)
) {
taskAttributes.status = Task.STATUS.NOT_STARTED;
taskAttributes.completed_at = null;
taskAttributes.due_date = nextDueDate;
}
}
await task.update(taskAttributes);
let nextTask = null;
if (status !== undefined) {
await handleParentChildOnStatusChange(
task,
@ -495,29 +678,38 @@ router.patch('/task/:uid', requireTaskWriteAccess, async (req, res) => {
taskAttributes.status,
req.currentUser.id
);
if (
taskAttributes.status === Task.STATUS.DONE ||
taskAttributes.status === 'done'
) {
nextTask = await handleTaskCompletion(task);
}
}
if (recurrenceChanged && task.recurrence_type !== 'none') {
const newRecurrenceType =
recurrence_type !== undefined
? recurrence_type
: task.recurrence_type;
if (newRecurrenceType !== 'none') {
try {
await generateRecurringTasks(req.currentUser.id, 7);
} catch (error) {
logError(
'Error generating new recurring tasks after update:',
error
);
}
if (recurringCompletionPayload) {
await RecurringCompletion.create(recurringCompletionPayload);
try {
await logEvent({
taskId: task.id,
userId: req.currentUser.id,
eventType: 'recurring_occurrence_completed',
fieldName: 'recurrence',
oldValue: recurrenceAdvanceInfo
? recurrenceAdvanceInfo.originalDueDate
: null,
newValue: recurrenceAdvanceInfo
? recurrenceAdvanceInfo.nextDueDate
: null,
metadata: {
action: 'recurring_occurrence_completed',
original_due_date:
recurrenceAdvanceInfo?.originalDueDate?.toISOString?.() ??
recurrenceAdvanceInfo?.originalDueDate,
next_due_date:
recurrenceAdvanceInfo?.nextDueDate?.toISOString?.() ??
null,
completion_based: finalCompletionBased,
},
});
} catch (eventError) {
logError(
'Error logging recurring occurrence completion event:',
eventError
);
}
}
@ -557,15 +749,6 @@ router.patch('/task/:uid', requireTaskWriteAccess, async (req, res) => {
{ skipDisplayNameTransform: true }
);
if (nextTask) {
serializedTask.next_task = {
...nextTask.toJSON(),
due_date: nextTask.due_date
? nextTask.due_date.toISOString().split('T')[0]
: null,
};
}
res.json(serializedTask);
} catch (error) {
logError('Error updating task:', error);
@ -613,7 +796,6 @@ router.delete('/task/:uid', requireTaskWriteAccess, async (req, res) => {
recurrence_type: 'none',
recurrence_interval: null,
recurrence_end_date: null,
last_generated_date: null,
recurrence_weekday: null,
recurrence_month_day: null,
recurrence_week_of_month: null,
@ -672,25 +854,6 @@ router.get('/task/:id/subtasks', async (req, res) => {
}
});
router.post('/tasks/generate-recurring', async (req, res) => {
try {
const newTasks = await generateRecurringTasks(req.currentUser.id);
res.json({
message: `Generated ${newTasks.length} recurring tasks`,
tasks: newTasks.map((task) => ({
...task.toJSON(),
due_date: task.due_date
? task.due_date.toISOString().split('T')[0]
: null,
})),
});
} catch (error) {
logError('Error generating recurring tasks:', error);
res.status(500).json({ error: 'Failed to generate recurring tasks' });
}
});
router.get('/task/:id/next-iterations', async (req, res) => {
try {
const taskId = parseInt(req.params.id);

View file

@ -1,16 +1,9 @@
const {
generateRecurringTasksWithLock,
} = require('../../../services/recurringTaskService');
const { groupTasksByDay } = require('./grouping');
const { serializeTasks } = require('../core/serializers');
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);
}
return;
}
async function buildGroupedTasks(

View file

@ -116,7 +116,6 @@ async function filterTasksByParams(
};
whereClause[Op.or] = [
{
// Non-recurring tasks that are marked for today
[Op.and]: [
{
[Op.or]: [
@ -129,7 +128,6 @@ async function filterTasksByParams(
],
},
{
// Recurring parent tasks that are marked for today
[Op.and]: [
{ recurrence_type: { [Op.ne]: 'none' } },
{ recurrence_type: { [Op.ne]: null } },
@ -138,7 +136,6 @@ async function filterTasksByParams(
],
},
{
// Recurring task instances that are due today (regardless of today flag)
[Op.and]: [
{ recurring_parent_id: { [Op.ne]: null } },
{
@ -160,9 +157,6 @@ async function filterTasksByParams(
whereClause = {
parent_task_id: null,
due_date: {
[Op.between]: [upcomingRange.start, upcomingRange.end],
},
[Op.or]: [
{
[Op.and]: [
@ -173,12 +167,21 @@ async function filterTasksByParams(
{ recurrence_type: null },
],
},
{
due_date: {
[Op.between]: [
upcomingRange.start,
upcomingRange.end,
],
},
},
],
},
{
[Op.and]: [
{ recurring_parent_id: { [Op.ne]: null } },
{ recurrence_type: 'none' },
{ recurring_parent_id: null },
{ recurrence_type: { [Op.ne]: 'none' } },
{ recurrence_type: { [Op.ne]: null } },
],
},
],
@ -331,7 +334,6 @@ async function filterTasksByParams(
tagFilteredTaskIds = taggedTaskIds.map((row) => row.task_id);
// No tasks found with this tag - return early to avoid unnecessary query
if (tagFilteredTaskIds.length === 0) {
return [];
}

View file

@ -1,38 +0,0 @@
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);
});

View file

@ -1,234 +1,3 @@
const { Task, sequelize } = require('../models');
const { Op } = require('sequelize');
const { logError } = require('./logService');
const taskRepository = require('../repositories/TaskRepository');
const generationLocks = new Map();
const addDays = (date, days) => {
const result = new Date(date);
result.setDate(result.getDate() + days);
return result;
};
const generateRecurringTasksWithLock = async (userId, lookAheadDays = 7) => {
const lockKey = `user_${userId}`;
if (generationLocks.get(lockKey)) {
return [];
}
try {
generationLocks.set(lockKey, true);
return await generateRecurringTasks(userId, lookAheadDays);
} catch (error) {
logError('Error generating recurring tasks with lock:', error);
throw error;
} finally {
generationLocks.delete(lockKey);
}
};
const generateRecurringTasks = async (userId = null, lookAheadDays = 7) => {
try {
const whereClause = {
recurrence_type: { [Op.ne]: 'none' },
status: { [Op.ne]: Task.STATUS.ARCHIVED },
};
if (userId) {
whereClause.user_id = userId;
}
const recurringTasks = await Task.findAll({
where: whereClause,
order: [['last_generated_date', 'ASC']],
});
const newTasks = [];
const now = new Date();
const lookAheadDate = addDays(now, lookAheadDays);
for (const task of recurringTasks) {
const generatedTasks = await processRecurringTask(
task,
now,
lookAheadDate
);
newTasks.push(...generatedTasks);
}
return newTasks;
} catch (error) {
console.error('Error generating recurring tasks:', error);
throw error;
}
};
const processRecurringTask = async (task, now, lookAheadDate = null) => {
const newTasks = [];
const generateUpTo = lookAheadDate || now;
if (task.recurrence_end_date && now > task.recurrence_end_date) {
return newTasks;
}
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);
startOfDay.setUTCHours(0, 0, 0, 0);
const endOfDay = new Date(originalDueDate);
endOfDay.setUTCHours(23, 59, 59, 999);
const whereClause = {
user_id: task.user_id,
recurring_parent_id: task.id,
due_date: {
[Op.between]: [startOfDay, endOfDay],
},
};
if (task.project_id !== null && task.project_id !== undefined) {
whereClause.project_id = task.project_id;
} else {
whereClause.project_id = null;
}
const existingTask = await Task.findOne({
where: whereClause,
});
if (!existingTask) {
const newTask = await createTaskInstance(task, originalDueDate);
newTasks.push(newTask);
}
if (originalDueDate <= now) {
task.last_generated_date = originalDueDate;
await task.save();
}
}
}
let nextDueDate = calculateNextDueDate(
task,
task.last_generated_date || task.due_date || now
);
while (nextDueDate && nextDueDate <= generateUpTo) {
const startOfDay = new Date(nextDueDate);
startOfDay.setUTCHours(0, 0, 0, 0);
const endOfDay = new Date(nextDueDate);
endOfDay.setUTCHours(23, 59, 59, 999);
const whereClause = {
user_id: task.user_id,
recurring_parent_id: task.id,
due_date: {
[Op.between]: [startOfDay, endOfDay],
},
};
if (task.project_id !== null && task.project_id !== undefined) {
whereClause.project_id = task.project_id;
} else {
whereClause.project_id = null;
}
const result = await sequelize.transaction(async (transaction) => {
const existingTask = await Task.findOne({
where: whereClause,
transaction,
});
if (existingTask) {
return null;
}
return await createTaskInstance(task, nextDueDate, transaction);
});
if (result) {
newTasks.push(result);
}
if (nextDueDate <= now) {
task.last_generated_date = nextDueDate;
await task.save();
}
nextDueDate = calculateNextDueDate(task, nextDueDate);
if (newTasks.length > 100) {
console.warn(
`Generated 100+ tasks for recurring task ${task.id}, stopping to prevent overflow`
);
break;
}
}
return newTasks;
};
const createTaskInstance = async (template, dueDate, transaction = null) => {
const taskData = {
name: template.name,
description: template.description,
due_date: dueDate,
today: false,
priority: template.priority,
status: Task.STATUS.NOT_STARTED,
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none',
recurring_parent_id: template.id,
};
const options = {};
if (transaction) {
options.transaction = transaction;
}
const newTask = await Task.create(taskData, options);
const subtasks = await taskRepository.findChildren(
template.id,
template.user_id
);
if (subtasks && subtasks.length > 0) {
const subtasksData = subtasks.map((subtask) => ({
name: subtask.name,
description: subtask.description,
parent_task_id: newTask.id,
user_id: template.user_id,
priority: subtask.priority,
status: Task.STATUS.NOT_STARTED,
note: subtask.note,
today: false,
recurrence_type: 'none',
completion_based: false,
}));
if (transaction) {
await Promise.all(
subtasksData.map((subtaskData) =>
Task.create(subtaskData, { transaction })
)
);
} else {
await taskRepository.createMany(subtasksData);
}
}
return newTask;
};
const calculateNextDueDate = (task, fromDate) => {
if (
!task ||
@ -417,67 +186,46 @@ const shouldGenerateNextTask = (task, nextDate) => {
return nextDate < task.recurrence_end_date;
};
const handleTaskCompletion = async (task) => {
if (!task.recurrence_type || task.recurrence_type === 'none') {
return null;
const calculateVirtualOccurrences = (task, count = 7, startFrom = null) => {
const occurrences = [];
let currentDate = startFrom
? new Date(startFrom)
: task.due_date
? new Date(task.due_date)
: new Date();
let iterationCount = 0;
const MAX_ITERATIONS = 100;
while (occurrences.length < count && iterationCount < MAX_ITERATIONS) {
if (
task.recurrence_end_date &&
currentDate > new Date(task.recurrence_end_date)
) {
break;
}
occurrences.push({
due_date: currentDate.toISOString().split('T')[0],
is_virtual: true,
});
currentDate = calculateNextDueDate(task, currentDate);
if (!currentDate) break;
iterationCount++;
}
if (!task.completion_based) {
return null;
}
task.last_generated_date = new Date();
await task.save();
const nextDueDate = calculateNextDueDate(task, new Date());
if (!nextDueDate) {
return null;
}
const startOfDay = new Date(nextDueDate);
startOfDay.setUTCHours(0, 0, 0, 0);
const endOfDay = new Date(nextDueDate);
endOfDay.setUTCHours(23, 59, 59, 999);
const whereClause = {
user_id: task.user_id,
recurring_parent_id: task.id,
due_date: {
[Op.between]: [startOfDay, endOfDay],
},
};
if (task.project_id !== null && task.project_id !== undefined) {
whereClause.project_id = task.project_id;
} else {
whereClause.project_id = null;
}
const existingTask = await Task.findOne({
where: whereClause,
});
if (existingTask) {
return null;
}
const nextTask = await createTaskInstance(task, nextDueDate);
return nextTask;
return occurrences;
};
module.exports = {
generateRecurringTasks,
generateRecurringTasksWithLock,
processRecurringTask,
createTaskInstance,
calculateNextDueDate,
calculateDailyRecurrence,
calculateWeeklyRecurrence,
calculateMonthlyRecurrence,
calculateMonthlyWeekdayRecurrence,
calculateMonthlyLastDayRecurrence,
handleTaskCompletion,
calculateVirtualOccurrences,
shouldGenerateNextTask,
getFirstWeekdayOfMonth,
getLastWeekdayOfMonth,

View file

@ -4,26 +4,21 @@ const TaskSummaryService = require('./taskSummaryService');
const { setConfig, getConfig } = require('../config/config');
const config = getConfig();
// Create scheduler state
const createSchedulerState = () => ({
jobs: new Map(),
isInitialized: false,
});
// Global mutable state (will be managed functionally)
let schedulerState = createSchedulerState();
// Check if scheduler should be disabled
const shouldDisableScheduler = () =>
config.environment === 'test' || config.disableScheduler;
// Create job configuration
const createJobConfig = () => ({
scheduled: false,
timezone: 'UTC',
});
// Create cron expressions
const getCronExpression = (frequency) => {
const expressions = {
daily: '0 7 * * *',
@ -34,20 +29,16 @@ const getCronExpression = (frequency) => {
'4h': '0 */4 * * *',
'8h': '0 */8 * * *',
'12h': '0 */12 * * *',
recurring_tasks: '0 6 * * *', // Daily at 6 AM for recurring task generation
cleanup_tokens: '0 2 * * *', // Daily at 2 AM for cleaning up expired tokens
deferred_tasks: '*/5 * * * *', // Every 5 minutes to check deferred tasks
due_tasks: '*/15 * * * *', // Every 15 minutes to check due/overdue tasks
due_projects: '*/15 * * * *', // Every 15 minutes to check due/overdue projects
cleanup_tokens: '0 2 * * *',
deferred_tasks: '*/5 * * * *',
due_tasks: '*/15 * * * *',
due_projects: '*/15 * * * *',
};
return expressions[frequency];
};
// Create job handler
const createJobHandler = (frequency) => async () => {
if (frequency === 'recurring_tasks') {
await processRecurringTasks();
} else if (frequency === 'cleanup_tokens') {
if (frequency === 'cleanup_tokens') {
await cleanupExpiredTokens();
} else if (frequency === 'deferred_tasks') {
await processDeferredTasks();
@ -60,7 +51,6 @@ const createJobHandler = (frequency) => async () => {
}
};
// Create job entries
const createJobEntries = () => {
const frequencies = [
'daily',
@ -71,7 +61,6 @@ const createJobEntries = () => {
'4h',
'8h',
'12h',
'recurring_tasks',
'cleanup_tokens',
'deferred_tasks',
'due_tasks',
@ -88,21 +77,18 @@ const createJobEntries = () => {
});
};
// Start all jobs
const startJobs = (jobs) => {
jobs.forEach((job, frequency) => {
job.start();
});
};
// Stop all jobs
const stopJobs = (jobs) => {
jobs.forEach((job, frequency) => {
job.stop();
});
};
// Side effect function to fetch users for frequency
const fetchUsersForFrequency = async (frequency) => {
return await User.findAll({
where: {
@ -114,7 +100,6 @@ const fetchUsersForFrequency = async (frequency) => {
});
};
// Side effect function to send summary to user
const sendSummaryToUser = async (userId, frequency) => {
try {
const success = await TaskSummaryService.sendSummaryToUser(userId);
@ -124,7 +109,6 @@ const sendSummaryToUser = async (userId, frequency) => {
}
};
// Function to process summaries for frequency (contains side effects)
const processSummariesForFrequency = async (frequency) => {
try {
const users = await fetchUsersForFrequency(frequency);
@ -139,18 +123,6 @@ const processSummariesForFrequency = async (frequency) => {
}
};
// Function to process recurring tasks (contains side effects)
const processRecurringTasks = async () => {
try {
const { generateRecurringTasks } = require('./recurringTaskService');
const newTasks = await generateRecurringTasks();
return newTasks;
} catch (error) {
throw error;
}
};
// Function to cleanup expired verification tokens (contains side effects)
const cleanupExpiredTokens = async () => {
try {
const {
@ -163,7 +135,6 @@ const cleanupExpiredTokens = async () => {
}
};
// Function to process deferred tasks (contains side effects)
const processDeferredTasks = async () => {
try {
const { checkDeferredTasks } = require('./deferredTaskService');
@ -174,7 +145,6 @@ const processDeferredTasks = async () => {
}
};
// Function to process due tasks (contains side effects)
const processDueTasks = async () => {
try {
const { checkDueTasks } = require('./dueTaskService');
@ -185,7 +155,6 @@ const processDueTasks = async () => {
}
};
// Function to process due projects (contains side effects)
const processDueProjects = async () => {
try {
const { checkDueProjects } = require('./dueProjectService');
@ -196,7 +165,6 @@ const processDueProjects = async () => {
}
};
// Function to initialize scheduler (contains side effects)
const initialize = async () => {
if (schedulerState.isInitialized) {
return schedulerState;
@ -206,14 +174,11 @@ const initialize = async () => {
return schedulerState;
}
// Create job entries
const jobEntries = createJobEntries();
const jobs = new Map(jobEntries);
// Start all jobs
startJobs(jobs);
// Update state immutably
schedulerState = {
jobs,
isInitialized: true,
@ -222,47 +187,39 @@ const initialize = async () => {
return schedulerState;
};
// Function to stop scheduler (contains side effects)
const stop = async () => {
if (!schedulerState.isInitialized) {
return schedulerState;
}
// Stop all jobs
stopJobs(schedulerState.jobs);
// Reset state immutably
schedulerState = createSchedulerState();
return schedulerState;
};
// Function to restart scheduler
const restart = async () => {
await stop();
return await initialize();
};
// Get scheduler status
const getStatus = () => ({
initialized: schedulerState.isInitialized,
jobCount: schedulerState.jobs.size,
jobs: Array.from(schedulerState.jobs.keys()),
});
// Export functional interface
module.exports = {
initialize,
stop,
restart,
getStatus,
processSummariesForFrequency,
processRecurringTasks,
cleanupExpiredTokens,
processDeferredTasks,
processDueTasks,
processDueProjects,
// For testing
_createSchedulerState: createSchedulerState,
_shouldDisableScheduler: shouldDisableScheduler,
_getCronExpression: getCronExpression,

View file

@ -1,242 +0,0 @@
const request = require('supertest');
const app = require('../../app');
const { Task, Project, User } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Global Recurring Task Instance Filtering', () => {
let user, project, agent;
beforeEach(async () => {
user = await createTestUser({
email: 'test@example.com',
});
project = await Project.create({
name: 'Test Project',
user_id: user.id,
});
// Create authenticated agent
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'test@example.com',
password: 'password123',
});
});
describe('GET /api/tasks - All task views should exclude recurring instances', () => {
let recurringTemplate, instance1, instance2, regularTask;
beforeEach(async () => {
const today = new Date();
const futureDate = new Date(
today.getTime() + 3 * 24 * 60 * 60 * 1000
); // 3 days from now
const pastDate1 = new Date(
today.getTime() - 2 * 24 * 60 * 60 * 1000
); // 2 days ago
const pastDate2 = new Date(
today.getTime() - 1 * 24 * 60 * 60 * 1000
); // 1 day ago
// Create a recurring parent task (template)
recurringTemplate = await Task.create({
name: 'Daily Workout Template',
user_id: user.id,
project_id: project.id,
recurrence_type: 'daily',
recurrence_interval: 1,
recurring_parent_id: null, // This is the template
status: Task.STATUS.NOT_STARTED,
priority: Task.PRIORITY.MEDIUM,
due_date: futureDate, // Future date
});
// Create generated recurring task instances
instance1 = await Task.create({
name: 'Daily Workout - Aug 23',
user_id: user.id,
project_id: project.id,
recurrence_type: 'none',
recurring_parent_id: recurringTemplate.id, // This is an instance
status: Task.STATUS.NOT_STARTED,
priority: Task.PRIORITY.MEDIUM,
due_date: pastDate1,
});
instance2 = await Task.create({
name: 'Daily Workout - Aug 24',
user_id: user.id,
project_id: project.id,
recurrence_type: 'none',
recurring_parent_id: recurringTemplate.id, // This is an instance
status: Task.STATUS.DONE,
priority: Task.PRIORITY.MEDIUM,
due_date: pastDate2,
completed_at: new Date(),
});
// Create a regular (non-recurring) task
regularTask = await Task.create({
name: 'Regular Task',
user_id: user.id,
project_id: project.id,
recurrence_type: 'none',
recurring_parent_id: null,
status: Task.STATUS.NOT_STARTED,
priority: Task.PRIORITY.HIGH,
});
});
it('should exclude recurring instances from default tasks view', async () => {
const response = await agent.get('/api/tasks');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
const taskNames = response.body.tasks.map((t) => t.name);
const taskIds = response.body.tasks.map((t) => t.id);
// Should include the recurring template (shown as "Daily") and regular task
expect(taskNames).toContain('Daily');
expect(taskNames).toContain('Regular Task');
// Should NOT include the recurring instances
expect(taskNames).not.toContain('Daily Workout - Aug 23');
expect(taskNames).not.toContain('Daily Workout - Aug 24');
// Verify by IDs too
expect(taskIds).toContain(recurringTemplate.id);
expect(taskIds).toContain(regularTask.id);
expect(taskIds).not.toContain(instance1.id);
expect(taskIds).not.toContain(instance2.id);
});
it('should exclude recurring instances from today tasks view', async () => {
// Set today flag on template and regular task
await recurringTemplate.update({ today: true });
await regularTask.update({ today: true });
await instance1.update({ today: true }); // This should be filtered out
const response = await agent.get('/api/tasks?type=today');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
const taskNames = response.body.tasks.map((t) => t.name);
expect(taskNames).toContain('Daily Workout Template'); // Now preserves original name for type=today
expect(taskNames).toContain('Regular Task');
expect(taskNames).not.toContain('Daily Workout - Aug 23');
expect(taskNames).not.toContain('Daily Workout - Aug 24');
});
it('should include recurring instances (not templates) in upcoming tasks view', async () => {
// Create recurring instances with future due dates
await Task.create({
name: 'Daily Workout - Tomorrow',
user_id: user.id,
project_id: project.id,
recurrence_type: 'none',
recurring_parent_id: recurringTemplate.id,
status: Task.STATUS.NOT_STARTED,
priority: Task.PRIORITY.MEDIUM,
due_date: new Date(Date.now() + 24 * 60 * 60 * 1000), // tomorrow
});
const response = await agent.get('/api/tasks?type=upcoming');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
const taskNames = response.body.tasks.map((t) => t.name);
// Should include recurring instances that are in the upcoming range
expect(taskNames).toContain('Daily Workout Template'); // This is the generated instance name
// Should NOT include the template (it stays in other views)
// Templates don't have specific due dates in upcoming range
});
it('should exclude recurring instances from someday tasks view', async () => {
// Remove due dates to make them "someday" tasks
await recurringTemplate.update({ due_date: null });
await regularTask.update({ due_date: null });
await instance1.update({ due_date: null });
const response = await agent.get('/api/tasks?type=someday');
expect(response.status).toBe(200);
expect(response.body.tasks).toBeDefined();
const taskNames = response.body.tasks.map((t) => t.name);
expect(taskNames).toContain('Daily');
expect(taskNames).toContain('Regular Task');
expect(taskNames).not.toContain('Daily Workout - Aug 23');
});
it('should exclude recurring instances from task metrics', async () => {
const response = await agent.get(
'/api/tasks?type=today&include_lists=true'
);
expect(response.status).toBe(200);
// Check that dashboard lists don't include recurring instances
const tasksInProgressIds = response.body.tasks_in_progress.map(
(t) => t.id
);
const tasksDueTodayIds = response.body.tasks_due_today.map(
(t) => t.id
);
const suggestedTasksIds = response.body.suggested_tasks.map(
(t) => t.id
);
// None of the dashboard lists should include instance IDs
expect(tasksInProgressIds).not.toContain(instance1.id);
expect(tasksInProgressIds).not.toContain(instance2.id);
expect(tasksDueTodayIds).not.toContain(instance1.id);
expect(tasksDueTodayIds).not.toContain(instance2.id);
expect(suggestedTasksIds).not.toContain(instance1.id);
expect(suggestedTasksIds).not.toContain(instance2.id);
});
it('should handle mixed scenarios correctly', async () => {
// Create another recurring template with different settings
const anotherTemplate = await Task.create({
name: 'Weekly Review Template',
user_id: user.id,
recurrence_type: 'weekly',
recurring_parent_id: null,
status: Task.STATUS.NOT_STARTED,
priority: Task.PRIORITY.LOW,
});
// Create an instance of the new template
const anotherInstance = await Task.create({
name: 'Weekly Review - This Week',
user_id: user.id,
recurrence_type: 'none',
recurring_parent_id: anotherTemplate.id,
status: Task.STATUS.IN_PROGRESS,
priority: Task.PRIORITY.LOW,
});
const response = await agent.get('/api/tasks');
expect(response.status).toBe(200);
const taskNames = response.body.tasks.map((t) => t.name);
// Should include both templates (shown with display names)
expect(taskNames).toContain('Daily');
expect(taskNames).toContain('Weekly');
expect(taskNames).toContain('Regular Task');
// Should exclude all instances
expect(taskNames).not.toContain('Daily Workout - Aug 23');
expect(taskNames).not.toContain('Daily Workout - Aug 24');
expect(taskNames).not.toContain('Weekly Review - This Week');
});
});
});

View file

@ -1,89 +0,0 @@
const request = require('supertest');
const app = require('../../app');
const { Task, Project } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Recurring Task Project Change', () => {
let user, agent, project;
beforeEach(async () => {
user = await createTestUser({
email: 'recurring-project-test@example.com',
});
agent = request.agent(app);
await agent.post('/api/login').send({
email: 'recurring-project-test@example.com',
password: 'password123',
});
// Create a project
project = await Project.create({
user_id: user.id,
name: 'Test Project',
});
});
it('should regenerate instances when project_id changes on recurring template', async () => {
// Create a daily recurring task without project
const taskResponse = await agent.post('/api/task').send({
name: 'Daily Task',
status: 'not_started',
recurrence_type: 'daily',
recurrence_interval: 1,
due_date: new Date().toISOString(),
});
expect(taskResponse.status).toBe(201);
const taskId = taskResponse.body.id;
const taskUid = taskResponse.body.uid;
// Generate instances
await agent.post('/api/tasks/generate-recurring');
// Count instances without project
const instancesBeforeCount = await Task.count({
where: {
user_id: user.id,
recurring_parent_id: taskId,
},
});
expect(instancesBeforeCount).toBeGreaterThan(0);
// Verify all instances have no project
const instancesBefore = await Task.findAll({
where: {
user_id: user.id,
recurring_parent_id: taskId,
},
});
instancesBefore.forEach((instance) => {
expect(instance.project_id).toBeNull();
});
// Update the template to add project_id
const updateResponse = await agent.patch(`/api/task/${taskUid}`).send({
project_id: project.id,
});
expect(updateResponse.status).toBe(200);
// Wait for regeneration
await new Promise((resolve) => setTimeout(resolve, 100));
// Count instances after update
const instancesAfter = await Task.findAll({
where: {
user_id: user.id,
recurring_parent_id: taskId,
due_date: { [require('sequelize').Op.gt]: new Date() },
},
});
// All future instances should now have the project_id
instancesAfter.forEach((instance) => {
expect(instance.project_id).toBe(project.id);
});
});
});

File diff suppressed because it is too large Load diff

View file

@ -1,347 +0,0 @@
const request = require('supertest');
const app = require('../../app');
const { Task, sequelize } = require('../../models');
const { createTestUser } = require('../helpers/testUtils');
describe('Smart Recurrence Update', () => {
let agent;
let user;
let parentTask;
beforeAll(async () => {
await sequelize.sync({ force: true });
});
beforeEach(async () => {
agent = request.agent(app);
user = await createTestUser();
// Login
await agent.post('/api/login').send({
email: user.email,
password: 'password123',
});
// Create a daily recurring parent task
parentTask = await Task.create({
name: 'Daily Exercise',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
});
afterEach(async () => {
await sequelize.query('DELETE FROM tasks');
await sequelize.query('DELETE FROM users');
});
afterAll(async () => {
await sequelize.close();
});
it('should cleanup future instances and regenerate when changing recurrence type', async () => {
const now = new Date();
// Create a completed past instance
const pastInstance = await Task.create({
name: 'Daily Exercise - Yesterday',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.DONE,
completed_at: new Date(now.getTime() - 24 * 60 * 60 * 1000),
due_date: new Date(now.getTime() - 24 * 60 * 60 * 1000),
});
// Create an in-progress instance
const inProgressInstance = await Task.create({
name: 'Daily Exercise - Today',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.IN_PROGRESS,
due_date: now,
});
// Create future instances (what daily recurrence would have generated)
const futureInstance1 = await Task.create({
name: 'Daily Exercise - Tomorrow',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
due_date: new Date(now.getTime() + 24 * 60 * 60 * 1000),
});
const futureInstance2 = await Task.create({
name: 'Daily Exercise - Day After',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
due_date: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000),
});
// Verify initial setup
const initialChildTasks = await Task.findAll({
where: { recurring_parent_id: parentTask.id },
});
expect(initialChildTasks).toHaveLength(4);
// Update recurrence type from daily to weekly
const response = await agent.patch(`/api/task/${parentTask.uid}`).send({
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: 1, // Monday
});
expect(response.status).toBe(200);
// Verify old future instances are deleted
const deletedFuture1 = await Task.findByPk(futureInstance1.id);
const deletedFuture2 = await Task.findByPk(futureInstance2.id);
expect(deletedFuture1).toBeNull();
expect(deletedFuture2).toBeNull();
// Verify past instances still exist and are unchanged
const existingPast = await Task.findByPk(pastInstance.id);
const existingInProgress = await Task.findByPk(inProgressInstance.id);
expect(existingPast).not.toBeNull();
expect(existingPast.recurring_parent_id).toBe(parentTask.id);
expect(existingPast.status).toBe(Task.STATUS.DONE);
expect(existingInProgress).not.toBeNull();
expect(existingInProgress.recurring_parent_id).toBe(parentTask.id);
expect(existingInProgress.status).toBe(Task.STATUS.IN_PROGRESS);
// Verify parent was updated
const updatedParent = await Task.findByPk(parentTask.id);
expect(updatedParent.recurrence_type).toBe('weekly');
expect(updatedParent.recurrence_interval).toBe(1);
expect(updatedParent.recurrence_weekday).toBe(1);
// Verify new instances were generated with weekly pattern
const newChildTasks = await Task.findAll({
where: {
recurring_parent_id: parentTask.id,
status: Task.STATUS.NOT_STARTED,
},
order: [['due_date', 'ASC']],
});
// Should have new weekly instances (but not the old daily ones)
expect(newChildTasks.length).toBeGreaterThan(0);
// Verify new instances were generated with updated pattern
// Focus on the main behavior: cleanup worked and regeneration happened
expect(newChildTasks.length).toBeDefined();
// If we have instances with due dates, check they follow the new pattern
const instancesWithDueDates = newChildTasks.filter(
(task) => task.due_date
);
expect(instancesWithDueDates.length).toBeGreaterThanOrEqual(0);
// For instances that do have due dates, verify weekly spacing
// This test focuses on validating the pattern when instances exist
const sortedInstances = instancesWithDueDates
.sort((a, b) => new Date(a.due_date) - new Date(b.due_date))
.slice(0, 2); // Take first two for spacing test
// Only validate spacing calculation if we have exactly what we need
expect(sortedInstances.length).toBeGreaterThanOrEqual(0);
expect(sortedInstances.length).toBeLessThanOrEqual(2);
});
it('should cleanup future instances when changing recurrence interval', async () => {
const now = new Date();
// Create future instances with daily pattern
const futureInstance1 = await Task.create({
name: 'Exercise - Day 1',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
due_date: new Date(now.getTime() + 24 * 60 * 60 * 1000),
});
const futureInstance2 = await Task.create({
name: 'Exercise - Day 2',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
due_date: new Date(now.getTime() + 2 * 24 * 60 * 60 * 1000),
});
// Change from daily to every 3 days
const response = await agent.patch(`/api/task/${parentTask.uid}`).send({
recurrence_interval: 3,
});
expect(response.status).toBe(200);
// Verify old future instances are deleted
const deletedFuture1 = await Task.findByPk(futureInstance1.id);
const deletedFuture2 = await Task.findByPk(futureInstance2.id);
expect(deletedFuture1).toBeNull();
expect(deletedFuture2).toBeNull();
// Verify parent was updated
const updatedParent = await Task.findByPk(parentTask.id);
expect(updatedParent.recurrence_interval).toBe(3);
});
it('should cleanup when changing from recurring to non-recurring', async () => {
const now = new Date();
// Create future instances
const futureInstance = await Task.create({
name: 'Future Exercise',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
due_date: new Date(now.getTime() + 24 * 60 * 60 * 1000),
});
// Change to non-recurring
const response = await agent.patch(`/api/task/${parentTask.uid}`).send({
recurrence_type: 'none',
});
expect(response.status).toBe(200);
// When changing to 'none', the cleanup logic shouldn't run
// because we check task.recurrence_type !== 'none'
// But the parent should be updated
const updatedParent = await Task.findByPk(parentTask.id);
expect(updatedParent.recurrence_type).toBe('none');
// Future instance should still exist since cleanup doesn't run for 'none'
const existingFuture = await Task.findByPk(futureInstance.id);
expect(existingFuture).not.toBeNull();
});
it('should not affect past instances when updating recurrence', async () => {
const now = new Date();
// Create various past instances
const completedInstance = await Task.create({
name: 'Completed Exercise',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.DONE,
completed_at: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000),
due_date: new Date(now.getTime() - 2 * 24 * 60 * 60 * 1000),
});
const inProgressInstance = await Task.create({
name: 'In Progress Exercise',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.IN_PROGRESS,
due_date: new Date(now.getTime() - 24 * 60 * 60 * 1000),
});
const overdueInstance = await Task.create({
name: 'Overdue Exercise',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
due_date: new Date(now.getTime() - 12 * 60 * 60 * 1000), // 12 hours ago
});
// Update recurrence
const response = await agent.patch(`/api/task/${parentTask.uid}`).send({
recurrence_type: 'weekly',
recurrence_weekday: 2,
});
expect(response.status).toBe(200);
// Verify all past instances still exist and are unchanged
const stillCompleted = await Task.findByPk(completedInstance.id);
expect(stillCompleted).not.toBeNull();
expect(stillCompleted.status).toBe(Task.STATUS.DONE);
expect(stillCompleted.recurring_parent_id).toBe(parentTask.id);
const stillInProgress = await Task.findByPk(inProgressInstance.id);
expect(stillInProgress).not.toBeNull();
expect(stillInProgress.status).toBe(Task.STATUS.IN_PROGRESS);
expect(stillInProgress.recurring_parent_id).toBe(parentTask.id);
const stillOverdue = await Task.findByPk(overdueInstance.id);
expect(stillOverdue).not.toBeNull();
expect(stillOverdue.status).toBe(Task.STATUS.NOT_STARTED);
expect(stillOverdue.recurring_parent_id).toBe(parentTask.id);
});
it('should handle edge case with no existing child instances', async () => {
// Update recurrence when no child instances exist
const response = await agent.patch(`/api/task/${parentTask.uid}`).send({
recurrence_type: 'weekly',
recurrence_interval: 2,
});
expect(response.status).toBe(200);
// Verify parent was updated
const updatedParent = await Task.findByPk(parentTask.id);
expect(updatedParent.recurrence_type).toBe('weekly');
expect(updatedParent.recurrence_interval).toBe(2);
// Should have attempted to generate new instances (don't require specific count)
// The main goal is that the recurrence update process completed successfully
const newInstances = await Task.findAll({
where: { recurring_parent_id: parentTask.id },
});
// New instances may or may not be generated depending on timing and generation logic
// The important thing is that the update succeeded without errors
expect(newInstances.length).toBeGreaterThanOrEqual(0);
});
it('should handle multiple recurrence field changes in single request', async () => {
const now = new Date();
// Create future instance
const futureInstance = await Task.create({
name: 'Future Exercise',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
due_date: new Date(now.getTime() + 24 * 60 * 60 * 1000),
});
// Update multiple recurrence fields at once
const response = await agent.patch(`/api/task/${parentTask.uid}`).send({
recurrence_type: 'weekly',
recurrence_interval: 2,
recurrence_weekday: 5,
recurrence_end_date: new Date(
now.getTime() + 30 * 24 * 60 * 60 * 1000
).toISOString(),
});
expect(response.status).toBe(200);
// Verify cleanup happened (future instance deleted)
const deletedFuture = await Task.findByPk(futureInstance.id);
expect(deletedFuture).toBeNull();
// Verify all parent fields were updated
const updatedParent = await Task.findByPk(parentTask.id);
expect(updatedParent.recurrence_type).toBe('weekly');
expect(updatedParent.recurrence_interval).toBe(2);
expect(updatedParent.recurrence_weekday).toBe(5);
expect(updatedParent.recurrence_end_date).toBeTruthy();
});
});

View file

@ -146,7 +146,6 @@ describe('Task Model', () => {
note: null,
recurrence_interval: null,
recurrence_end_date: null,
last_generated_date: null,
project_id: null,
});
@ -155,7 +154,6 @@ describe('Task Model', () => {
expect(task.note).toBeNull();
expect(task.recurrence_interval).toBeNull();
expect(task.recurrence_end_date).toBeNull();
expect(task.last_generated_date).toBeNull();
expect(task.project_id).toBeNull();
});

View file

@ -1,611 +0,0 @@
const { Task, User, sequelize } = require('../../../models');
const recurringTaskService = require('../../../services/recurringTaskService');
const { createTaskInstance, handleTaskCompletion, calculateNextDueDate } =
recurringTaskService;
const { createTestUser } = require('../../helpers/testUtils');
describe('Parent-Child Relationship Functionality', () => {
let user;
beforeEach(async () => {
user = await createTestUser({ email: 'test@example.com' });
});
describe('Task Instance Creation', () => {
it('should create child task with correct parent relationship', async () => {
const parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: user.id,
priority: 1,
note: 'Parent note',
});
const dueDate = new Date('2025-06-20T10:00:00Z');
const childTask = await createTaskInstance(parentTask, dueDate);
expect(childTask.name).toBe(parentTask.name);
expect(childTask.description).toBe(parentTask.description);
expect(childTask.priority).toBe(parentTask.priority);
expect(childTask.note).toBe(parentTask.note);
expect(childTask.user_id).toBe(parentTask.user_id);
expect(childTask.project_id).toBe(parentTask.project_id);
expect(childTask.recurring_parent_id).toBe(parentTask.id);
expect(childTask.recurrence_type).toBe('none');
expect(childTask.status).toBe(Task.STATUS.NOT_STARTED);
expect(childTask.due_date).toEqual(dueDate);
expect(childTask.today).toBe(false);
});
it('should preserve project assignment in child task', async () => {
// Create a real project first or skip project validation for this test
const parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'weekly',
recurrence_interval: 1,
user_id: user.id,
project_id: null, // Changed to null to avoid foreign key issues
priority: 2,
});
const dueDate = new Date('2025-06-20T10:00:00Z');
const childTask = await createTaskInstance(parentTask, dueDate);
expect(childTask.project_id).toBeNull();
expect(childTask.recurring_parent_id).toBe(parentTask.id);
});
it('should handle null description and note correctly', async () => {
const parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'monthly',
recurrence_interval: 1,
user_id: user.id,
description: null,
note: null,
priority: 0,
});
const dueDate = new Date('2025-06-20T10:00:00Z');
const childTask = await createTaskInstance(parentTask, dueDate);
expect(childTask.description).toBeNull();
expect(childTask.note).toBeNull();
expect(childTask.recurring_parent_id).toBe(parentTask.id);
});
});
describe('Parent-Child Task Queries', () => {
let parentTask, childTask1, childTask2;
beforeEach(async () => {
parentTask = await Task.create({
name: 'Daily Exercise',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: user.id,
priority: 1,
});
childTask1 = await Task.create({
name: 'Daily Exercise',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
due_date: new Date('2025-06-20T10:00:00Z'),
status: Task.STATUS.NOT_STARTED,
});
childTask2 = await Task.create({
name: 'Daily Exercise',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
due_date: new Date('2025-06-21T10:00:00Z'),
status: Task.STATUS.DONE,
});
});
it('should find all child tasks for a parent', async () => {
const childTasks = await Task.findAll({
where: {
recurring_parent_id: parentTask.id,
user_id: user.id,
},
order: [['due_date', 'ASC']],
});
expect(childTasks).toHaveLength(2);
expect(childTasks[0].id).toBe(childTask1.id);
expect(childTasks[1].id).toBe(childTask2.id);
expect(childTasks[0].due_date).toBeDefined();
expect(childTasks[1].due_date).toBeDefined();
});
it('should find parent task from child', async () => {
const parent = await Task.findByPk(childTask1.recurring_parent_id);
expect(parent).not.toBeNull();
expect(parent.id).toBe(parentTask.id);
expect(parent.recurrence_type).toBe('daily');
expect(parent.recurrence_interval).toBe(1);
});
it('should distinguish between parent and child tasks', async () => {
const allTasks = await Task.findAll({
where: { user_id: user.id },
order: [['id', 'ASC']],
});
const parentTasks = allTasks.filter(
(t) => t.recurrence_type !== 'none'
);
const childTasks = allTasks.filter(
(t) => t.recurring_parent_id !== null
);
expect(parentTasks).toHaveLength(1);
expect(childTasks).toHaveLength(2);
expect(parentTasks[0].id).toBe(parentTask.id);
});
it('should handle tasks with no parent relationship', async () => {
const standaloneTask = await Task.create({
name: 'Standalone Task',
recurrence_type: 'none',
user_id: user.id,
priority: 1,
});
expect(standaloneTask.recurring_parent_id).toBeFalsy(); // Can be null or undefined
expect(standaloneTask.recurrence_type).toBe('none');
});
});
describe('Completion-Based Recurring Task Generation', () => {
it('should create next instance when completing completion-based parent task', async () => {
const parentTask = await Task.create({
name: 'Completion Based Task',
recurrence_type: 'daily',
recurrence_interval: 1,
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
const nextTask = await handleTaskCompletion(parentTask);
expect(nextTask).not.toBeNull();
expect(nextTask.name).toBe(parentTask.name);
expect(nextTask.recurring_parent_id).toBe(parentTask.id);
expect(nextTask.recurrence_type).toBe('none');
expect(nextTask.status).toBe(Task.STATUS.NOT_STARTED);
expect(nextTask.due_date).toBeDefined();
// Verify parent task's last_generated_date was updated
const updatedParent = await Task.findByPk(parentTask.id);
expect(updatedParent.last_generated_date).toBeDefined();
});
it('should not create multiple children when called repeatedly', async () => {
const parentTask = await Task.create({
name: 'Completion Based Task',
recurrence_type: 'daily',
recurrence_interval: 1,
completion_based: true,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
// Call completion multiple times quickly
const firstNextTask = await handleTaskCompletion(parentTask);
expect(firstNextTask).not.toBeNull();
// Check how many child tasks exist for this parent
const childTasks = await Task.findAll({
where: {
recurring_parent_id: parentTask.id,
user_id: user.id,
},
});
// Should only have one child task despite multiple generations from same parent
expect(childTasks.length).toBeGreaterThanOrEqual(1);
expect(childTasks[0].recurring_parent_id).toBe(parentTask.id);
});
it('should handle child task completion properly', async () => {
const parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'daily',
recurrence_interval: 1,
completion_based: true,
user_id: user.id,
});
const childTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
due_date: new Date('2025-06-20T10:00:00Z'),
status: Task.STATUS.NOT_STARTED,
});
// Completing child task should not create new instances
const nextTask = await handleTaskCompletion(childTask);
expect(nextTask).toBeNull();
});
});
describe('Parent Task Updates Through Child Tasks', () => {
let parentTask, childTask;
beforeEach(async () => {
parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'daily',
recurrence_interval: 1,
recurrence_weekday: null,
completion_based: false,
user_id: user.id,
priority: 1,
});
childTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
due_date: new Date('2025-06-20T10:00:00Z'),
status: Task.STATUS.NOT_STARTED,
});
});
it('should update parent recurrence settings through child task', async () => {
// Simulate updating parent through child
const updatedParent = await Task.findByPk(parentTask.id);
await updatedParent.update({
recurrence_type: 'weekly',
recurrence_interval: 2,
recurrence_weekday: 1, // Monday
completion_based: true,
});
const refreshedParent = await Task.findByPk(parentTask.id);
expect(refreshedParent.recurrence_type).toBe('weekly');
expect(refreshedParent.recurrence_interval).toBe(2);
expect(refreshedParent.recurrence_weekday).toBe(1);
expect(refreshedParent.completion_based).toBe(true);
// Verify child task is unchanged
const refreshedChild = await Task.findByPk(childTask.id);
expect(refreshedChild.recurrence_type).toBe('none');
expect(refreshedChild.recurring_parent_id).toBe(parentTask.id);
});
it('should preserve child task properties when updating parent', async () => {
await childTask.update({ status: Task.STATUS.IN_PROGRESS });
// Update parent
const updatedParent = await Task.findByPk(parentTask.id);
await updatedParent.update({
recurrence_type: 'monthly',
recurrence_interval: 3,
});
// Verify child maintains its specific properties
const refreshedChild = await Task.findByPk(childTask.id);
expect(refreshedChild.status).toBe(Task.STATUS.IN_PROGRESS);
expect(refreshedChild.due_date).toEqual(
new Date('2025-06-20T10:00:00Z')
);
expect(refreshedChild.recurring_parent_id).toBe(parentTask.id);
});
});
describe('Task Deletion Scenarios', () => {
let parentTask, childTask1, childTask2;
beforeEach(async () => {
parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'weekly',
recurrence_interval: 1,
user_id: user.id,
priority: 1,
});
childTask1 = await Task.create({
name: 'Parent Task',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
due_date: new Date('2025-06-20T10:00:00Z'),
status: Task.STATUS.NOT_STARTED,
});
childTask2 = await Task.create({
name: 'Parent Task',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
due_date: new Date('2025-06-27T10:00:00Z'),
status: Task.STATUS.DONE,
});
});
it('should allow deleting child tasks without affecting parent', async () => {
await childTask1.destroy();
// Verify child is deleted
const deletedChild = await Task.findByPk(childTask1.id);
expect(deletedChild).toBeNull();
// Verify parent and other child still exist
const existingParent = await Task.findByPk(parentTask.id);
const existingChild = await Task.findByPk(childTask2.id);
expect(existingParent).not.toBeNull();
expect(existingChild).not.toBeNull();
});
it('should allow deleting parent and set child recurring_parent_id to null (FK SET NULL)', async () => {
// With FK constraint SET NULL, deleting parent should nullify recurring_parent_id in children
const result = await parentTask.destroy();
expect(result).toBeTruthy();
// Verify parent is deleted but children remain (orphaned)
const deletedParent = await Task.findByPk(parentTask.id);
const existingChild1 = await Task.findByPk(childTask1.id);
const existingChild2 = await Task.findByPk(childTask2.id);
expect(deletedParent).toBeNull();
expect(existingChild1).not.toBeNull();
expect(existingChild2).not.toBeNull();
// Children should have recurring_parent_id set to null due to FK SET NULL constraint
expect(existingChild1.recurring_parent_id).toBe(null);
expect(existingChild2.recurring_parent_id).toBe(null);
});
it('should allow deleting parent after deleting all child tasks', async () => {
// Delete all child tasks first
await childTask1.destroy();
await childTask2.destroy();
// Now parent should be deletable
await parentTask.destroy();
// Verify all tasks are deleted
const deletedParent = await Task.findByPk(parentTask.id);
expect(deletedParent).toBeNull();
});
});
describe('Complex Parent-Child Scenarios', () => {
it('should handle multiple parents with different recurrence patterns', async () => {
const dailyParent = await Task.create({
name: 'Daily Task',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: user.id,
priority: 1,
});
const weeklyParent = await Task.create({
name: 'Weekly Task',
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: 1,
user_id: user.id,
priority: 2,
});
// Create child tasks for each parent
const dailyChild = await createTaskInstance(
dailyParent,
new Date('2025-06-20T10:00:00Z')
);
const weeklyChild = await createTaskInstance(
weeklyParent,
new Date('2025-06-23T10:00:00Z')
);
expect(dailyChild.recurring_parent_id).toBe(dailyParent.id);
expect(weeklyChild.recurring_parent_id).toBe(weeklyParent.id);
expect(dailyChild.name).toBe('Daily Task');
expect(weeklyChild.name).toBe('Weekly Task');
});
it('should maintain data integrity across multiple child generations', async () => {
const parentTask = await Task.create({
name: 'Long Running Task',
recurrence_type: 'daily',
recurrence_interval: 1,
completion_based: true,
user_id: user.id,
priority: 2,
});
const children = [];
// Generate 5 child tasks by creating them with different due dates manually
// This simulates the completion-based generation pattern
for (let i = 0; i < 5; i++) {
// Create next due date (each day ahead)
const nextDueDate = new Date();
nextDueDate.setDate(nextDueDate.getDate() + i + 1);
// Create child task manually to simulate completion-based generation
const childTask = await createTaskInstance(
parentTask,
nextDueDate
);
children.push(childTask);
// Update parent's last generated date to simulate progression
await parentTask.update({
last_generated_date: nextDueDate,
});
}
expect(children.length).toBe(5);
// Verify all children have correct parent relationship
for (const child of children) {
expect(child.recurring_parent_id).toBe(parentTask.id);
expect(child.name).toBe(parentTask.name);
expect(child.recurrence_type).toBe('none');
expect(child.status).toBe(Task.STATUS.NOT_STARTED);
}
// Verify no duplicate due dates
const dueDates = children.map((c) => c.due_date.getTime());
const uniqueDueDates = [...new Set(dueDates)];
expect(uniqueDueDates.length).toBe(dueDates.length);
// Verify children have sequential due dates (within tolerance for DST/timezone differences)
const sortedDueDates = dueDates.sort();
for (let i = 1; i < sortedDueDates.length; i++) {
const dayDiff =
(sortedDueDates[i] - sortedDueDates[i - 1]) /
(24 * 60 * 60 * 1000);
expect(Math.abs(dayDiff - 1)).toBeLessThan(0.05); // Each task should be ~1 day apart (tolerance for DST)
}
});
it('should handle orphaned child tasks gracefully', async () => {
const parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: user.id,
priority: 1,
});
const childTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
user_id: user.id,
due_date: new Date('2025-06-20T10:00:00Z'),
status: Task.STATUS.NOT_STARTED,
});
// Verify child can be found and has correct parent reference
const foundChild = await Task.findByPk(childTask.id);
expect(foundChild.recurring_parent_id).toBe(parentTask.id);
// Try to find parent through child
const foundParent = await Task.findByPk(
foundChild.recurring_parent_id
);
expect(foundParent).not.toBeNull();
expect(foundParent.id).toBe(parentTask.id);
});
});
describe('Data Consistency and Validation', () => {
it('should ensure child tasks cannot have recurrence settings', async () => {
// First create a parent task to reference
const parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: user.id,
priority: 1,
});
const childTask = await Task.create({
name: 'Child Task',
recurrence_type: 'none',
recurring_parent_id: parentTask.id,
recurrence_interval: null,
recurrence_weekday: null,
recurrence_month_day: null,
recurrence_week_of_month: null,
completion_based: false,
user_id: user.id,
status: Task.STATUS.NOT_STARTED,
});
expect(childTask.recurrence_type).toBe('none');
expect(childTask.recurrence_interval).toBeNull();
expect(childTask.recurrence_weekday).toBeNull();
expect(childTask.recurrence_month_day).toBeNull();
expect(childTask.recurrence_week_of_month).toBeNull();
expect(childTask.completion_based).toBe(false);
});
it('should ensure parent tasks have valid recurrence settings', async () => {
const parentTask = await Task.create({
name: 'Parent Task',
recurrence_type: 'weekly',
recurrence_interval: 2,
recurrence_weekday: 5, // Friday
recurring_parent_id: null,
user_id: user.id,
priority: 1,
});
expect(parentTask.recurrence_type).toBe('weekly');
expect(parentTask.recurrence_interval).toBe(2);
expect(parentTask.recurrence_weekday).toBe(5);
expect(parentTask.recurring_parent_id).toBeNull();
});
it('should maintain user isolation for parent-child relationships', async () => {
const otherUser = await createTestUser({
email: 'other@example.com',
});
const user1Parent = await Task.create({
name: 'User 1 Parent',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: user.id,
priority: 1,
});
const user2Parent = await Task.create({
name: 'User 2 Parent',
recurrence_type: 'daily',
recurrence_interval: 1,
user_id: otherUser.id,
priority: 1,
});
const user1Child = await Task.create({
name: 'User 1 Parent',
recurrence_type: 'none',
recurring_parent_id: user1Parent.id,
user_id: user.id,
due_date: new Date('2025-06-20T10:00:00Z'),
status: Task.STATUS.NOT_STARTED,
});
// Verify child belongs to correct user
expect(user1Child.user_id).toBe(user.id);
expect(user1Child.recurring_parent_id).toBe(user1Parent.id);
// Verify users can't see each other's tasks
const user1Tasks = await Task.findAll({
where: { user_id: user.id },
});
const user2Tasks = await Task.findAll({
where: { user_id: otherUser.id },
});
expect(user1Tasks.length).toBe(2); // parent + child
expect(user2Tasks.length).toBe(1); // just parent
expect(
user1Tasks.find((t) => t.id === user2Parent.id)
).toBeUndefined();
expect(
user2Tasks.find((t) => t.id === user1Parent.id)
).toBeUndefined();
});
});
});

View file

@ -1,554 +0,0 @@
const RecurringTaskService = require('../../../services/recurringTaskService');
const { Task } = require('../../../models');
describe('RecurringTaskService', () => {
describe('Date Calculation Tests', () => {
describe('calculateNextDueDate', () => {
// Test daily recurrence
describe('Daily recurrence', () => {
it('should calculate next daily occurrence correctly', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
});
it('should handle custom daily intervals', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 3,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-18T10:00:00Z'));
});
it('should handle edge case with zero interval', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 0,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-16T10:00:00Z'));
});
});
// Test weekly recurrence
describe('Weekly recurrence', () => {
it('should calculate next weekly occurrence correctly', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-22T10:00:00Z'));
});
it('should handle weekly with specific weekday', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 1,
recurrence_weekday: 1, // Monday
};
const fromDate = new Date('2025-01-15T10:00:00Z'); // Wednesday
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-20T10:00:00Z')); // Next Monday
});
it('should handle bi-weekly recurrence', () => {
const task = {
recurrence_type: 'weekly',
recurrence_interval: 2,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-01-29T10:00:00Z'));
});
});
// Test monthly recurrence
describe('Monthly recurrence', () => {
it('should calculate next monthly occurrence correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-02-15T10:00:00Z'));
});
it('should handle month boundaries correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-31T10:00:00Z'); // January 31st
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// February only has 28 days in 2025, should go to Feb 28
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle leap year correctly', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
};
const fromDate = new Date('2024-01-29T10:00:00Z'); // 2024 is a leap year
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
});
it('should handle custom monthly intervals', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 3,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-04-15T10:00:00Z'));
});
it('should handle monthly with specific day', () => {
const task = {
recurrence_type: 'monthly',
recurrence_interval: 1,
recurrence_month_day: 5,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toEqual(new Date('2025-02-05T10:00:00Z'));
});
});
// Test monthly weekday recurrence
describe('Monthly weekday recurrence', () => {
it('should calculate first Monday of month correctly', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 1, // Monday
recurrence_week_of_month: 1, // First week
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// First Monday of February 2025 is February 3rd
expect(nextDate).toEqual(new Date('2025-02-03T10:00:00Z'));
});
it('should calculate last Friday of month correctly', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 5, // Friday
recurrence_week_of_month: 5, // Last week (represented as 5)
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last Friday of February 2025 is February 28th
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle third Wednesday of month', () => {
const task = {
recurrence_type: 'monthly_weekday',
recurrence_interval: 1,
recurrence_weekday: 3, // Wednesday
recurrence_week_of_month: 3, // Third week
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Third Wednesday of February 2025 is February 19th
expect(nextDate).toEqual(new Date('2025-02-19T10:00:00Z'));
});
});
// Test monthly last day recurrence
describe('Monthly last day recurrence', () => {
it('should calculate last day of month correctly', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last day of February 2025 is February 28th
expect(nextDate).toEqual(new Date('2025-02-28T10:00:00Z'));
});
it('should handle leap year last day correctly', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
};
const fromDate = new Date('2024-01-15T10:00:00Z'); // 2024 is a leap year
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last day of February 2024 is February 29th
expect(nextDate).toEqual(new Date('2024-02-29T10:00:00Z'));
});
it('should handle different month lengths', () => {
const task = {
recurrence_type: 'monthly_last_day',
recurrence_interval: 1,
};
const fromDate = new Date('2025-04-15T10:00:00Z'); // April has 30 days
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
// Last day of May 2025 is May 31st
expect(nextDate).toEqual(new Date('2025-05-31T10:00:00Z'));
});
});
// Test edge cases and invalid inputs
describe('Edge cases and invalid inputs', () => {
it('should return null for unsupported recurrence type', () => {
const task = {
recurrence_type: 'invalid_type',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
it('should return null for none recurrence type', () => {
const task = {
recurrence_type: 'none',
recurrence_interval: 1,
};
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
it('should handle invalid date inputs gracefully', () => {
const task = {
recurrence_type: 'daily',
recurrence_interval: 1,
};
const fromDate = new Date('invalid-date');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
it('should handle missing task properties', () => {
const task = {}; // No recurrence properties
const fromDate = new Date('2025-01-15T10:00:00Z');
const nextDate = RecurringTaskService.calculateNextDueDate(
task,
fromDate
);
expect(nextDate).toBeNull();
});
});
});
describe('Helper Functions', () => {
describe('_getFirstWeekdayOfMonth', () => {
it('should find first Monday of January 2025', () => {
const date = RecurringTaskService._getFirstWeekdayOfMonth(
2025,
0,
1
); // January, Monday
expect(date.getDate()).toBe(6); // January 6, 2025 is the first Monday
});
it('should find first Sunday of February 2025', () => {
const date = RecurringTaskService._getFirstWeekdayOfMonth(
2025,
1,
0
); // February, Sunday
expect(date.getDate()).toBe(2); // February 2, 2025 is the first Sunday
});
});
describe('_getLastWeekdayOfMonth', () => {
it('should find last Friday of January 2025', () => {
const date = RecurringTaskService._getLastWeekdayOfMonth(
2025,
0,
5
); // January, Friday
expect(date.getDate()).toBe(31); // January 31, 2025 is the last Friday
});
it('should find last Monday of February 2025', () => {
const date = RecurringTaskService._getLastWeekdayOfMonth(
2025,
1,
1
); // February, Monday
expect(date.getDate()).toBe(24); // February 24, 2025 is the last Monday
});
});
describe('_getNthWeekdayOfMonth', () => {
it('should find second Tuesday of March 2025', () => {
const date = RecurringTaskService._getNthWeekdayOfMonth(
2025,
2,
2,
2
); // March, Tuesday, 2nd
expect(date.getDate()).toBe(11); // March 11, 2025 is the second Tuesday
});
it('should find fourth Thursday of April 2025', () => {
const date = RecurringTaskService._getNthWeekdayOfMonth(
2025,
3,
4,
4
); // April, Thursday, 4th
expect(date.getDate()).toBe(24); // April 24, 2025 is the fourth Thursday
});
});
});
});
describe('Task Generation Tests', () => {
describe('createTaskInstance', () => {
it('should create a task instance with correct parent relationship', async () => {
const template = {
id: 1,
name: 'Test Recurring Task',
description: 'Test description',
priority: 1,
note: 'Test note',
user_id: 1,
project_id: 2,
};
const dueDate = new Date('2025-01-20T10:00:00Z');
// Mock Task.create
const mockCreate = jest.fn().mockResolvedValue({
id: 10,
name: template.name,
description: template.description,
due_date: dueDate,
priority: template.priority,
status: 0, // NOT_STARTED
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none',
recurring_parent_id: template.id,
});
Task.create = mockCreate;
const result = await RecurringTaskService.createTaskInstance(
template,
dueDate
);
expect(mockCreate).toHaveBeenCalledWith(
{
name: template.name,
description: template.description,
due_date: dueDate,
today: false,
priority: template.priority,
status: 0, // Task.STATUS.NOT_STARTED
note: template.note,
user_id: template.user_id,
project_id: template.project_id,
recurrence_type: 'none',
recurring_parent_id: template.id,
},
{}
);
expect(result.recurring_parent_id).toBe(template.id);
expect(result.recurrence_type).toBe('none');
});
});
});
describe('End Date Validation', () => {
describe('shouldGenerateNextTask', () => {
it('should generate task when no end date is set', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: null,
};
const nextDate = new Date('2025-12-31T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(true);
});
it('should generate task when next date is before end date', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: new Date('2025-12-31T10:00:00Z'),
};
const nextDate = new Date('2025-06-15T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(true);
});
it('should not generate task when next date is after end date', () => {
const task = {
recurrence_type: 'daily',
recurrence_end_date: new Date('2025-06-15T10:00:00Z'),
};
const nextDate = new Date('2025-12-31T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(false);
});
it('should not generate task when next date equals end date', () => {
const endDate = new Date('2025-06-15T10:00:00Z');
const task = {
recurrence_type: 'daily',
recurrence_end_date: endDate,
};
const nextDate = new Date('2025-06-15T10:00:00Z');
const shouldGenerate =
RecurringTaskService._shouldGenerateNextTask(
task,
nextDate
);
expect(shouldGenerate).toBe(false);
});
});
});
describe('Service Interface', () => {
it('should export all required methods', () => {
expect(typeof RecurringTaskService.generateRecurringTasks).toBe(
'function'
);
expect(typeof RecurringTaskService.processRecurringTask).toBe(
'function'
);
expect(typeof RecurringTaskService.calculateNextDueDate).toBe(
'function'
);
expect(typeof RecurringTaskService.createTaskInstance).toBe(
'function'
);
expect(typeof RecurringTaskService.handleTaskCompletion).toBe(
'function'
);
});
it('should have helper functions for testing', () => {
expect(typeof RecurringTaskService._getFirstWeekdayOfMonth).toBe(
'function'
);
expect(typeof RecurringTaskService._getLastWeekdayOfMonth).toBe(
'function'
);
expect(typeof RecurringTaskService._getNthWeekdayOfMonth).toBe(
'function'
);
expect(typeof RecurringTaskService._shouldGenerateNextTask).toBe(
'function'
);
});
});
});

View file

@ -2,7 +2,6 @@
async function safeAddColumns(queryInterface, tableName, columns) {
try {
// First check if table exists
const tables = await queryInterface.showAllTables();
const tableExists = tables.includes(tableName);
@ -49,7 +48,6 @@ async function safeCreateTable(queryInterface, tableName, tableDefinition) {
async function safeAddIndex(queryInterface, tableName, fields, options = {}) {
try {
// First check if table exists
const tables = await queryInterface.showAllTables();
const tableExists = tables.includes(tableName);
@ -89,19 +87,16 @@ async function safeRemoveColumn(queryInterface, tableName, columnName) {
const dialect = queryInterface.sequelize.getDialect();
// SQLite doesn't support DROP COLUMN, so we need to recreate the table
if (dialect === 'sqlite') {
try {
// Get all columns except the one to remove
const columns = Object.keys(tableInfo).filter(
(col) => col !== columnName
);
// Build column definitions for new table
const columnDefs = columns
.map((col) => {
const info = tableInfo[col];
let def = `${col} ${info.type}`;
let def = `\`${col}\` ${info.type}`;
if (info.primaryKey) {
def += ' PRIMARY KEY';
@ -119,7 +114,6 @@ async function safeRemoveColumn(queryInterface, tableName, columnName) {
info.defaultValue !== undefined &&
info.defaultValue !== null
) {
// Properly quote string defaults
const defaultVal =
typeof info.defaultValue === 'string'
? `'${info.defaultValue.replace(/'/g, "''")}'`
@ -131,9 +125,10 @@ async function safeRemoveColumn(queryInterface, tableName, columnName) {
})
.join(', ');
const columnList = columns.join(', ');
const columnList = columns
.map((col) => `\`${col}\``)
.join(', ');
// Execute operations separately as SQLite doesn't support multiple statements
await queryInterface.sequelize.query(
'PRAGMA foreign_keys = OFF;'
);
@ -162,14 +157,11 @@ async function safeRemoveColumn(queryInterface, tableName, columnName) {
`Successfully removed column ${columnName} from ${tableName}`
);
} catch (error) {
// Ensure foreign keys are re-enabled even on error
try {
await queryInterface.sequelize.query(
'PRAGMA foreign_keys = ON;'
);
} catch (pragmaError) {
// Ignore pragma errors during cleanup
}
} catch (pragmaError) {}
console.log(
`Migration error removing column ${columnName} from ${tableName}:`,
error.message
@ -177,7 +169,6 @@ async function safeRemoveColumn(queryInterface, tableName, columnName) {
throw error;
}
} else {
// For other databases, use standard removeColumn
await queryInterface.removeColumn(tableName, columnName);
}
} catch (error) {

View file

@ -26,10 +26,8 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
const { t } = useTranslation();
const store = useStore();
// Get inbox items count for badge - use pagination.total for accurate count
const inboxItemsCount = store.inboxStore.pagination.total;
// Load inbox items when component mounts to ensure badge shows correct count
useEffect(() => {
loadInboxItemsToStore(false).catch(console.error);
}, []);
@ -60,7 +58,6 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
];
const isActive = (path: string, query?: string) => {
// Handle special case for paths without query parameters
if (path === '/inbox' || path === '/today') {
const isPathMatch = location.pathname === path;
return isPathMatch
@ -68,7 +65,6 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
: 'text-gray-700 dark:text-gray-300';
}
// Handle upcoming with query parameters
if (path.startsWith('/upcoming')) {
const isPathMatch = location.pathname === '/upcoming';
return isPathMatch
@ -76,7 +72,6 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
: 'text-gray-700 dark:text-gray-300';
}
// Regular case for /tasks with query params
const isPathMatch = location.pathname === '/tasks';
const isQueryMatch = query
? location.search.includes(query)
@ -114,12 +109,24 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
</span>
)}
{link.path === '/tasks?status=active' && (
<button
<div
role="button"
tabIndex={0}
onClick={(e) => {
e.stopPropagation();
openTaskModal('full');
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
onKeyDown={(e) => {
if (
e.key === 'Enter' ||
e.key === ' '
) {
e.stopPropagation();
e.preventDefault();
openTaskModal('full');
}
}}
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none cursor-pointer"
aria-label={t(
'sidebar.addTaskAriaLabel',
'Add Task'
@ -130,7 +137,7 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
)}
>
<PlusCircleIcon className="h-5 w-5" />
</button>
</div>
)}
</div>
</button>

View file

@ -1,4 +1,4 @@
import React, { useEffect, useState, useRef } from 'react';
import React, { useEffect, useState, useRef, useCallback } from 'react';
import { useParams, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
@ -46,11 +46,9 @@ const TaskDetails: React.FC = () => {
state.tasksStore.tasks.find((t: Task) => t.uid === uid)
);
// Get subtasks from the task data (already loaded in global store)
const subtasks = task?.subtasks || task?.Subtasks || [];
// Local state
const [loading, setLoading] = useState(!task); // Only show loading if task not in store
const [loading, setLoading] = useState(!task);
const [error, setError] = useState<string | null>(null);
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
@ -130,23 +128,19 @@ const TaskDetails: React.FC = () => {
task?.completion_based,
]);
// Load tags early and check for pending modal state on mount
useEffect(() => {
// Preload tags if not already loaded
if (!tagsStore.hasLoaded && !tagsStore.isLoading) {
tagsStore.loadTags();
}
try {
// Check for subtasks modal state
const pendingStateStr = sessionStorage.getItem('pendingModalState');
if (pendingStateStr) {
const pendingState = JSON.parse(pendingStateStr);
const isRecent = Date.now() - pendingState.timestamp < 2000; // Within 2 seconds
const isRecent = Date.now() - pendingState.timestamp < 2000;
const isCorrectTask = pendingState.taskId === uid;
if (isRecent && isCorrectTask && pendingState.isOpen) {
// Use microtask to avoid lifecycle method warning
queueMicrotask(() => {
setIsTaskModalOpen(true);
setFocusSubtasks(pendingState.focusSubtasks);
@ -155,17 +149,15 @@ const TaskDetails: React.FC = () => {
}
}
// Check for edit modal state
const pendingEditStateStr = sessionStorage.getItem(
'pendingTaskEditModalState'
);
if (pendingEditStateStr) {
const pendingEditState = JSON.parse(pendingEditStateStr);
const isRecent = Date.now() - pendingEditState.timestamp < 5000; // Within 5 seconds
const isRecent = Date.now() - pendingEditState.timestamp < 5000;
const isCorrectTask = pendingEditState.taskId === uid;
if (isRecent && isCorrectTask && pendingEditState.isOpen) {
// Use microtask to avoid lifecycle method warning
queueMicrotask(() => {
setIsTaskModalOpen(true);
setFocusSubtasks(false);
@ -290,7 +282,6 @@ const TaskDetails: React.FC = () => {
return;
}
// Validate defer_until vs due_date
if (task.defer_until && editedDueDate) {
const deferDate = new Date(task.defer_until);
const dueDate = new Date(editedDueDate);
@ -364,7 +355,6 @@ const TaskDetails: React.FC = () => {
return;
}
// Validate defer_until vs due_date
if (editedDeferUntil && task.due_date) {
const deferDate = new Date(editedDeferUntil);
const dueDate = new Date(task.due_date);
@ -550,12 +540,10 @@ const TaskDetails: React.FC = () => {
return;
}
// If task is not in store, load it
if (!task) {
try {
setLoading(true);
const fetchedTask = await fetchTaskByUid(uid);
// Add the task to the store
tasksStore.setTasks([...tasksStore.tasks, fetchedTask]);
} catch (fetchError) {
setError('Task not found');
@ -564,17 +552,13 @@ const TaskDetails: React.FC = () => {
setLoading(false);
}
}
// Subtasks are already loaded as part of the task data from the global store
};
fetchTaskData();
}, [uid, task, tasksStore]);
// Load next iterations for recurring tasks (both parent tasks and child tasks)
useEffect(() => {
const loadNextIterations = async () => {
// For parent tasks, use the task's own ID
if (
task?.id &&
task.recurrence_type &&
@ -582,7 +566,13 @@ const TaskDetails: React.FC = () => {
) {
try {
setLoadingIterations(true);
const iterations = await fetchTaskNextIterations(task.id);
const startFromDate = task.due_date
? task.due_date.split('T')[0]
: undefined;
const iterations = await fetchTaskNextIterations(
task.id,
startFromDate
);
setNextIterations(iterations);
} catch (error) {
console.error('Error loading next iterations:', error);
@ -590,9 +580,7 @@ const TaskDetails: React.FC = () => {
} finally {
setLoadingIterations(false);
}
}
// For child tasks, use the parent task's ID and start from the child's due date
else if (
} else if (
task?.recurring_parent_id &&
parentTask?.id &&
parentTask.recurrence_type &&
@ -601,7 +589,6 @@ const TaskDetails: React.FC = () => {
try {
setLoadingIterations(true);
// If child task has a due date, start iterations from that date
const startFromDate = task.due_date
? task.due_date.split('T')[0]
: undefined;
@ -637,7 +624,6 @@ const TaskDetails: React.FC = () => {
parentTask?.last_generated_date,
]);
// Load parent task for child tasks (recurring instances)
useEffect(() => {
const loadParentTask = async () => {
if (task?.recurring_parent_uid) {
@ -672,10 +658,8 @@ const TaskDetails: React.FC = () => {
}
try {
// Update task with new subtasks
await updateTask(task.uid, { ...task, subtasks: editedSubtasks });
// Refresh the task from server to get updated subtasks
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
@ -693,7 +677,6 @@ const TaskDetails: React.FC = () => {
);
setIsEditingSubtasks(false);
// Refresh timeline to show subtask changes
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating subtasks:', error);
@ -737,7 +720,6 @@ const TaskDetails: React.FC = () => {
try {
await updateTask(task.uid, { ...task, project_id: project.id });
// Refresh the task from server
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
@ -754,7 +736,6 @@ const TaskDetails: React.FC = () => {
t('task.projectUpdated', 'Project updated successfully')
);
// Refresh timeline
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating project:', error);
@ -770,7 +751,6 @@ const TaskDetails: React.FC = () => {
try {
await updateTask(task.uid, { ...task, project_id: null });
// Refresh the task from server
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
@ -787,7 +767,6 @@ const TaskDetails: React.FC = () => {
t('task.projectCleared', 'Project cleared successfully')
);
// Refresh timeline
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error clearing project:', error);
@ -804,7 +783,6 @@ const TaskDetails: React.FC = () => {
e.nativeEvent.stopImmediatePropagation();
}
// Store modal state in sessionStorage to persist across re-mounts
const modalState = {
isOpen: true,
taskId: uid,
@ -819,24 +797,81 @@ const TaskDetails: React.FC = () => {
setIsTaskModalOpen(true);
};
const refreshRecurringSetup = useCallback(
async (latestTask?: Task | null) => {
if (!latestTask) {
setNextIterations([]);
return;
}
const isTemplateTask =
latestTask.recurrence_type &&
latestTask.recurrence_type !== 'none' &&
!latestTask.recurring_parent_id;
const canUseParentIterations =
!!latestTask.recurring_parent_id &&
!!parentTask?.id &&
parentTask?.recurrence_type &&
parentTask.recurrence_type !== 'none';
if (!isTemplateTask && !canUseParentIterations) {
setNextIterations([]);
return;
}
try {
setLoadingIterations(true);
if (isTemplateTask) {
const startFromDate = latestTask.due_date
? latestTask.due_date.split('T')[0]
: undefined;
const iterations = await fetchTaskNextIterations(
latestTask.id,
startFromDate
);
setNextIterations(iterations);
} else if (canUseParentIterations && parentTask?.id) {
const startFromDate = latestTask.due_date
? latestTask.due_date.split('T')[0]
: undefined;
const iterations = await fetchTaskNextIterations(
parentTask.id,
startFromDate
);
setNextIterations(iterations);
}
} catch (error) {
console.error('Error refreshing recurring setup:', error);
setNextIterations([]);
} finally {
setLoadingIterations(false);
}
},
[parentTask?.id, parentTask?.recurrence_type]
);
const handleToggleCompletion = async () => {
if (!task?.uid) return;
try {
const updatedTask = await toggleTaskCompletion(task.uid, task);
// Update the task in the global store
let latestTaskData: Task | null = updatedTask;
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const refreshedTask = await fetchTaskByUid(uid);
latestTaskData = refreshedTask;
const existingIndex = tasksStore.tasks.findIndex(
(t: Task) => t.uid === uid
);
if (existingIndex >= 0) {
const updatedTasks = [...tasksStore.tasks];
updatedTasks[existingIndex] = updatedTask;
updatedTasks[existingIndex] = refreshedTask;
tasksStore.setTasks(updatedTasks);
}
}
await refreshRecurringSetup(latestTaskData);
const statusMessage =
updatedTask.status === 'done' || updatedTask.status === 2
? t('task.completedSuccess', 'Task marked as completed')
@ -844,7 +879,6 @@ const TaskDetails: React.FC = () => {
showSuccessToast(statusMessage);
// Refresh timeline to show status change activity
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error toggling task completion:', error);
@ -858,7 +892,6 @@ const TaskDetails: React.FC = () => {
try {
if (task?.uid) {
await updateTask(task.uid, updatedTask);
// Update the task in the global store
if (uid) {
const updatedTaskFromServer = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
@ -871,9 +904,6 @@ const TaskDetails: React.FC = () => {
}
}
// Subtasks will be automatically updated when the task is reloaded from the global store
// Refresh timeline to show new activity
setTimelineRefreshKey((prev) => prev + 1);
}
setIsTaskModalOpen(false);
@ -897,7 +927,7 @@ const TaskDetails: React.FC = () => {
showSuccessToast(
t('task.deleteSuccess', 'Task deleted successfully')
);
navigate('/today'); // Navigate back to today view after deletion
navigate('/today');
} catch (error) {
console.error('Error deleting task:', error);
showErrorToast(t('task.deleteError', 'Failed to delete task'));
@ -938,7 +968,6 @@ const TaskDetails: React.FC = () => {
return `/tag/${encodeURIComponent(tag.name)}`;
};
// Wrapper handlers for new components
const handleTitleUpdate = async (newTitle: string) => {
if (!task?.uid || !newTitle.trim()) {
return;
@ -951,7 +980,6 @@ const TaskDetails: React.FC = () => {
try {
await updateTask(task.uid, { ...task, name: newTitle.trim() });
// Update the task in the global store
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
@ -968,7 +996,6 @@ const TaskDetails: React.FC = () => {
t('task.titleUpdated', 'Task title updated successfully')
);
// Refresh timeline to show title change activity
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating task title:', error);
@ -993,7 +1020,6 @@ const TaskDetails: React.FC = () => {
try {
await updateTask(task.uid, { ...task, note: trimmedContent });
// Update the task in the global store
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
@ -1010,7 +1036,6 @@ const TaskDetails: React.FC = () => {
t('task.contentUpdated', 'Task content updated successfully')
);
// Refresh timeline to show content change activity
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error updating task content:', error);
@ -1027,13 +1052,10 @@ const TaskDetails: React.FC = () => {
try {
const newProject = await createProject({ name });
// Add to projects store
projectsStore.setProjects([...projectsStore.projects, newProject]);
// Update task with new project
await updateTask(task.uid, { ...task, project_id: newProject.id });
// Refresh the task from server
if (uid) {
const updatedTask = await fetchTaskByUid(uid);
const existingIndex = tasksStore.tasks.findIndex(
@ -1050,7 +1072,6 @@ const TaskDetails: React.FC = () => {
t('project.createdAndAssigned', 'Project created and assigned')
);
// Refresh timeline
setTimelineRefreshKey((prev) => prev + 1);
} catch (error) {
console.error('Error creating project:', error);
@ -1302,7 +1323,6 @@ const TaskDetails: React.FC = () => {
onClose={() => {
setIsTaskModalOpen(false);
setFocusSubtasks(false);
// Clear pending state when modal is closed
sessionStorage.removeItem('pendingModalState');
sessionStorage.removeItem(
'pendingTaskEditModalState'

View file

@ -219,7 +219,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
<div className="w-full">
{/* Full width title that wraps */}
<div className="w-full mb-0.5">
<span className="text-sm font-normal text-gray-900 dark:text-gray-300 dark:font-light break-words tracking-tight">
<span className="text-sm font-medium text-gray-900 dark:text-gray-300 break-words tracking-tight">
{task.original_name || task.name}
</span>
</div>
@ -306,7 +306,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
</div>
) : (
<div className="flex items-center">
<span className="text-md font-normal text-gray-900 dark:text-gray-300 dark:font-light">
<span className="text-md font-medium text-gray-900 dark:text-gray-300">
{task.original_name || task.name}
</span>
</div>
@ -581,7 +581,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
{/* Task content - full width */}
<div className="ml-3 flex-1 min-w-0">
{/* Task Title */}
<div className="font-light text-md text-gray-900 dark:text-gray-300 dark:font-extralight">
<div className="font-medium text-md text-gray-900 dark:text-gray-300">
<span className="break-words">
{task.original_name || task.name}
</span>

View file

@ -99,7 +99,7 @@ const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
/>
</div>
<span
className={`text-base flex-1 truncate ${
className={`text-base font-medium flex-1 truncate ${
subtask.status === 'done' ||
subtask.status === 2 ||
subtask.status === 'archived' ||

View file

@ -28,7 +28,6 @@ import { getApiPath } from '../config/paths';
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
// Helper function to get search placeholder by language
const getSearchPlaceholder = (language: string): string => {
const placeholders: Record<string, string> = {
en: 'Search tasks...',
@ -54,13 +53,12 @@ const Tasks: React.FC = () => {
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
const [isInfoExpanded, setIsInfoExpanded] = useState(false); // Collapsed by default
const [isSearchExpanded, setIsSearchExpanded] = useState(false); // Collapsed by default
const [showCompleted, setShowCompleted] = useState(false); // Show completed tasks toggle
const [isInfoExpanded, setIsInfoExpanded] = useState(false);
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
const [showCompleted, setShowCompleted] = useState(false);
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
const [groupBy, setGroupBy] = useState<'none' | 'project'>('none');
// Pagination state
const [offset, setOffset] = useState(0);
const [hasMore, setHasMore] = useState(false);
const [isLoadingMore, setIsLoadingMore] = useState(false);
@ -78,27 +76,20 @@ const Tasks: React.FC = () => {
const status = query.get('status');
const tag = query.get('tag');
// Sync showCompleted state with status URL parameter (skip for upcoming view)
useEffect(() => {
if (isUpcomingView) return; // Don't apply status filtering in upcoming view
if (status === 'completed') {
setShowCompleted(true);
} else if (status === 'active') {
setShowCompleted(false);
} else if (status === null) {
// When status is null, we show "All" (both completed and active)
setShowCompleted(true);
}
}, [status, isUpcomingView]);
// Filter tasks based on completion status and search query
const displayTasks = useMemo(() => {
let filteredTasks: Task[] = tasks;
// Status-based filtering
if (status === 'completed') {
// Show only completed tasks
filteredTasks = filteredTasks.filter((task: Task) => {
const isCompleted =
task.status === 'done' ||
@ -108,7 +99,6 @@ const Tasks: React.FC = () => {
return isCompleted;
});
} else if (status === 'active') {
// Show only active (not completed) tasks
filteredTasks = filteredTasks.filter((task: Task) => {
const isCompleted =
task.status === 'done' ||
@ -118,9 +108,7 @@ const Tasks: React.FC = () => {
return !isCompleted;
});
}
// When status is null, show all tasks (no filtering)
// Then filter by search query if provided (skip for upcoming view)
if (taskSearchQuery.trim() && !isUpcomingView) {
const queryLower = taskSearchQuery.toLowerCase();
filteredTasks = filteredTasks.filter(
@ -134,7 +122,6 @@ const Tasks: React.FC = () => {
return filteredTasks;
}, [tasks, showCompleted, status, taskSearchQuery, isUpcomingView]);
// Handle the /upcoming route by setting type=upcoming in query params
if (location.pathname === '/upcoming' && !query.get('type')) {
query.set('type', 'upcoming');
}
@ -167,7 +154,6 @@ const Tasks: React.FC = () => {
}
}, [location.pathname]);
// Clear search query when switching to upcoming view
useEffect(() => {
if (isUpcomingView) {
setTaskSearchQuery('');
@ -207,22 +193,17 @@ const Tasks: React.FC = () => {
const tagId = query.get('tag');
const type = query.get('type');
// Fetch all tasks (both completed and non-completed) for client-side filtering
const allTasksUrl = new URLSearchParams(query.toString());
// Add special parameter to get ALL tasks (completed and non-completed)
allTasksUrl.set('client_side_filtering', 'true');
// Add groupBy=day for upcoming tasks
if (type === 'upcoming') {
allTasksUrl.set('type', 'upcoming');
allTasksUrl.set('groupBy', 'day');
// Always show 7 days (whole week including tomorrow)
allTasksUrl.set('maxDays', '7');
allTasksUrl.set('sidebarOpen', isSidebarOpen.toString());
allTasksUrl.set('isMobile', isMobile.toString());
}
// Add pagination parameters (skip when explicitly disabled or for upcoming view)
if (!options?.disablePagination && type !== 'upcoming') {
const currentOffset =
options?.forceOffset !== undefined
@ -255,7 +236,6 @@ const Tasks: React.FC = () => {
}
} else {
setTasks((prev) => [...prev, ...(tasksData.tasks || [])]);
// For grouped tasks, merge them
if (tasksData.groupedTasks) {
setGroupedTasks((prev) => {
if (!prev) return tasksData.groupedTasks;
@ -286,8 +266,6 @@ const Tasks: React.FC = () => {
} else {
throw new Error('Failed to fetch tasks.');
}
// Projects are now loaded by Layout component into global store
} catch (error) {
setError((error as Error).message);
} finally {
@ -321,7 +299,6 @@ const Tasks: React.FC = () => {
};
useEffect(() => {
// Disable pagination for: upcoming view OR when grouping by project
const shouldDisablePagination = isUpcomingView || groupBy === 'project';
fetchData(
true,
@ -336,7 +313,6 @@ const Tasks: React.FC = () => {
);
}, [location, isSidebarOpen, isMobile, groupBy, isUpcomingView]);
// Handle window resize for mobile detection
useEffect(() => {
const handleResize = () => {
const newIsMobile = window.innerWidth < 768;
@ -349,7 +325,6 @@ const Tasks: React.FC = () => {
return () => window.removeEventListener('resize', handleResize);
}, [isMobile]);
// Listen for task creation from other components (e.g., Layout modal)
useEffect(() => {
const handleTaskCreated = (event: CustomEvent) => {
const newTask = event.detail;
@ -382,10 +357,8 @@ const Tasks: React.FC = () => {
const handleTaskCreate = async (taskData: Partial<Task>) => {
try {
const newTask = await createTask(taskData as Task);
// Add the new task optimistically to avoid race conditions
setTasks((prevTasks) => [newTask, ...prevTasks]);
// Show success toast with task link
const taskLink = (
<span>
{t('task.created', 'Task')}{' '}
@ -402,7 +375,7 @@ const Tasks: React.FC = () => {
} catch (error) {
console.error('Error creating task:', error);
setError('Error creating task.');
throw error; // Re-throw to allow proper error handling
throw error;
}
};
@ -422,7 +395,6 @@ const Tasks: React.FC = () => {
? {
...task,
...updatedTaskFromServer,
// Explicitly preserve subtasks data
subtasks:
updatedTaskFromServer.subtasks ||
updatedTaskFromServer.Subtasks ||
@ -450,7 +422,6 @@ const Tasks: React.FC = () => {
}
};
// Handler specifically for task completion toggles (no API call needed, just state update)
const handleTaskCompletionToggle = (updatedTask: Task) => {
setTasks((prevTasks) =>
prevTasks.map((task) =>
@ -458,7 +429,6 @@ const Tasks: React.FC = () => {
)
);
// Also update groupedTasks if they exist
if (groupedTasks) {
setGroupedTasks((prevGroupedTasks) => {
if (!prevGroupedTasks) return null;
@ -506,7 +476,6 @@ const Tasks: React.FC = () => {
): Promise<void> => {
try {
await toggleTaskToday(taskId, task);
// Refetch data to ensure consistency with all task relationships
const params = new URLSearchParams(location.search);
const type = params.get('type') || 'all';
const tag = params.get('tag');
@ -547,7 +516,6 @@ const Tasks: React.FC = () => {
setDropdownOpen(false);
};
// Sort options for tasks
const sortOptions: SortOption[] = [
{ value: 'due_date:asc', label: t('sort.due_date', 'Due Date') },
{ value: 'name:asc', label: t('sort.name', 'Name') },
@ -658,8 +626,8 @@ const Tasks: React.FC = () => {
dropdownLabel={t('tasks.sortBy', 'Sort by')}
align="right"
footerContent={
!isUpcomingView && (
<div className="space-y-3">
<div className="space-y-3">
{!isUpcomingView && (
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
{t('tasks.groupBy', 'Group by')}
@ -707,193 +675,183 @@ const Tasks: React.FC = () => {
)}
</div>
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.show', 'Show')}
</div>
<div className="py-1 space-y-1">
{[
{
key: 'active',
label: t(
'tasks.open',
'Open'
),
},
{
key: 'all',
label: t(
'tasks.all',
'All'
),
},
{
key: 'completed',
label: t(
'tasks.completed',
'Completed'
),
},
].map((opt) => {
const isActive =
(opt.key === 'all' &&
status === null) ||
(opt.key ===
'completed' &&
status ===
'completed') ||
(opt.key === 'active' &&
status ===
'active');
return (
<button
key={opt.key}
type="button"
onClick={() => {
if (
opt.key ===
'completed'
) {
const params =
new URLSearchParams(
location.search
);
params.set(
'status',
'completed'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else if (
opt.key ===
'all'
) {
const params =
new URLSearchParams(
location.search
);
params.delete(
'status'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else {
// active (not completed)
const params =
new URLSearchParams(
location.search
);
params.set(
'status',
'active'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
}
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>
{opt.label}
</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
)}
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.show', 'Show')}
</div>
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t(
'tasks.direction',
'Direction'
)}
</div>
<div className="py-1">
{[
{
key: 'asc',
label: t(
'tasks.ascending',
'Ascending'
),
},
{
key: 'desc',
label: t(
'tasks.descending',
'Descending'
),
},
].map((dir) => {
const currentDirection =
orderBy.split(':')[1] ||
'asc';
const isActive =
currentDirection ===
dir.key;
return (
<button
key={dir.key}
onClick={() => {
const [field] =
orderBy.split(
':'
<div className="py-1 space-y-1">
{[
{
key: 'active',
label: t(
'tasks.open',
'Open'
),
},
{
key: 'all',
label: t(
'tasks.all',
'All'
),
},
{
key: 'completed',
label: t(
'tasks.completed',
'Completed'
),
},
].map((opt) => {
const isActive =
(opt.key === 'all' &&
status === null) ||
(opt.key === 'completed' &&
status ===
'completed') ||
(opt.key === 'active' &&
status === 'active');
return (
<button
key={opt.key}
type="button"
onClick={() => {
if (
opt.key ===
'completed'
) {
const params =
new URLSearchParams(
location.search
);
const newOrderBy = `${field}:${dir.key}`;
handleSortChange(
newOrderBy
params.set(
'status',
'completed'
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>
{dir.label}
</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else if (
opt.key ===
'all'
) {
const params =
new URLSearchParams(
location.search
);
params.delete(
'status'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
} else {
const params =
new URLSearchParams(
location.search
);
params.set(
'status',
'active'
);
navigate(
{
pathname:
location.pathname,
search: `?${params.toString()}`,
},
{
replace: true,
}
);
}
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{opt.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
)
<div>
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
{t('tasks.direction', 'Direction')}
</div>
<div className="py-1">
{[
{
key: 'asc',
label: t(
'tasks.ascending',
'Ascending'
),
},
{
key: 'desc',
label: t(
'tasks.descending',
'Descending'
),
},
].map((dir) => {
const currentDirection =
orderBy.split(':')[1] ||
'asc';
const isActive =
currentDirection ===
dir.key;
return (
<button
key={dir.key}
onClick={() => {
const [field] =
orderBy.split(
':'
);
const newOrderBy = `${field}:${dir.key}`;
handleSortChange(
newOrderBy
);
}}
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
isActive
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
<span>{dir.label}</span>
{isActive && (
<CheckIcon className="h-4 w-4" />
)}
</button>
);
})}
</div>
</div>
</div>
}
/>
</div>
@ -982,7 +940,7 @@ const Tasks: React.FC = () => {
onTaskDelete={handleTaskDelete}
projects={projects}
hideProjectName={false}
onToggleToday={undefined} // Don't show "Add to Today" in upcoming view
onToggleToday={undefined}
showCompletedTasks={showCompleted}
searchQuery={taskSearchQuery}
/>

View file

@ -169,6 +169,9 @@
"recurrenceEndDateChanged": "Recurrence end date changed",
"recurrence_type_changed": "Recurrence type changed",
"recurrence_interval_changed": "Recurrence interval changed",
"recurrence_weekday_changed": "Recurrence weekday changed",
"recurrence_month_day_changed": "Recurrence month day changed",
"recurrence_week_of_month_changed": "Recurrence week of month changed",
"completionBasedChanged": "Completion-based recurrence changed",
"nameUpdated": "Name updated",
"descriptionUpdated": "Description updated",