From cd6b810b08ff0412547f800becc9bf4cf0bf077a Mon Sep 17 00:00:00 2001 From: Chris Date: Thu, 4 Dec 2025 13:29:37 +0200 Subject: [PATCH] Fix recurring structure (#646) * Refactor recurring * fixup! Refactor recurring * Add after completion tests * fixup! Add after completion tests * fixup! fixup! Add after completion tests --- .../20250618000001-enhance-recurring-tasks.js | 151 +- .../20250619000001-add-recurring-parent-id.js | 55 +- ...2131-add-productivity-assistant-columns.js | 62 +- ...50722051746-add-today-settings-to-users.js | 28 +- ...00-add-task-sorting-columns-to-projects.js | 55 +- ...0251204000001-add-recurring-completions.js | 48 + ...51204000002-migrate-recurring-instances.js | 85 + backend/models/index.js | 25 +- backend/models/recurringCompletion.js | 47 + backend/models/task.js | 13 - backend/routes/tasks/core/builders.js | 57 +- backend/routes/tasks/core/serializers.js | 10 +- backend/routes/tasks/index.js | 289 ++- backend/routes/tasks/operations/list.js | 9 +- .../routes/tasks/queries/query-builders.js | 20 +- backend/scripts/generate-today-instances.js | 38 - backend/services/recurringTaskService.js | 310 +-- backend/services/taskScheduler.js | 53 +- .../global-recurring-filter.test.js | 242 --- .../recurring-project-change.test.js | 89 - .../tests/integration/recurring-tasks.test.js | 1730 ++++++++++------- .../smart-recurrence-update.test.js | 347 ---- backend/tests/unit/models/task.test.js | 2 - .../services/parentChildRelationship.test.js | 611 ------ .../services/recurringTaskService.test.js | 554 ------ backend/utils/migration-utils.js | 19 +- frontend/components/Sidebar/SidebarNav.tsx | 23 +- frontend/components/Task/TaskDetails.tsx | 126 +- frontend/components/Task/TaskHeader.tsx | 6 +- frontend/components/Task/TaskItem.tsx | 2 +- frontend/components/Tasks.tsx | 400 ++-- public/locales/en/translation.json | 3 + 32 files changed, 1957 insertions(+), 3552 deletions(-) create mode 100644 backend/migrations/20251204000001-add-recurring-completions.js create mode 100644 backend/migrations/20251204000002-migrate-recurring-instances.js create mode 100644 backend/models/recurringCompletion.js delete mode 100644 backend/scripts/generate-today-instances.js delete mode 100644 backend/tests/integration/global-recurring-filter.test.js delete mode 100644 backend/tests/integration/recurring-project-change.test.js delete mode 100644 backend/tests/integration/smart-recurrence-update.test.js delete mode 100644 backend/tests/unit/services/parentChildRelationship.test.js delete mode 100644 backend/tests/unit/services/recurringTaskService.test.js diff --git a/backend/migrations/20250618000001-enhance-recurring-tasks.js b/backend/migrations/20250618000001-enhance-recurring-tasks.js index 038c25c..fe445cb 100644 --- a/backend/migrations/20250618000001-enhance-recurring-tasks.js +++ b/backend/migrations/20250618000001-enhance-recurring-tasks.js @@ -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 + ); + } + }, }; diff --git a/backend/migrations/20250619000001-add-recurring-parent-id.js b/backend/migrations/20250619000001-add-recurring-parent-id.js index c592739..424f739 100644 --- a/backend/migrations/20250619000001-add-recurring-parent-id.js +++ b/backend/migrations/20250619000001-add-recurring-parent-id.js @@ -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'); }, }; diff --git a/backend/migrations/20250713072131-add-productivity-assistant-columns.js b/backend/migrations/20250713072131-add-productivity-assistant-columns.js index 0d664b8..272ade1 100644 --- a/backend/migrations/20250713072131-add-productivity-assistant-columns.js +++ b/backend/migrations/20250713072131-add-productivity-assistant-columns.js @@ -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' ); diff --git a/backend/migrations/20250722051746-add-today-settings-to-users.js b/backend/migrations/20250722051746-add-today-settings-to-users.js index c7048c6..fe6dd76 100644 --- a/backend/migrations/20250722051746-add-today-settings-to-users.js +++ b/backend/migrations/20250722051746-add-today-settings-to-users.js @@ -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'); }, }; diff --git a/backend/migrations/20250722052000-add-task-sorting-columns-to-projects.js b/backend/migrations/20250722052000-add-task-sorting-columns-to-projects.js index f567013..2cdd4fd 100644 --- a/backend/migrations/20250722052000-add-task-sorting-columns-to-projects.js +++ b/backend/migrations/20250722052000-add-task-sorting-columns-to-projects.js @@ -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'); }, }; diff --git a/backend/migrations/20251204000001-add-recurring-completions.js b/backend/migrations/20251204000001-add-recurring-completions.js new file mode 100644 index 0000000..c4c3f15 --- /dev/null +++ b/backend/migrations/20251204000001-add-recurring-completions.js @@ -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'); + }, +}; diff --git a/backend/migrations/20251204000002-migrate-recurring-instances.js b/backend/migrations/20251204000002-migrate-recurring-instances.js new file mode 100644 index 0000000..a83ea29 --- /dev/null +++ b/backend/migrations/20251204000002-migrate-recurring-instances.js @@ -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, + }, + }, + ]); + }, +}; diff --git a/backend/models/index.js b/backend/models/index.js index e7de6f4..c371c29 100644 --- a/backend/models/index.js +++ b/backend/models/index.js @@ -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, }; diff --git a/backend/models/recurringCompletion.js b/backend/models/recurringCompletion.js new file mode 100644 index 0000000..40a69e1 --- /dev/null +++ b/backend/models/recurringCompletion.js @@ -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; +}; diff --git a/backend/models/task.js b/backend/models/task.js index 4708951..0a78f99 100644 --- a/backend/models/task.js +++ b/backend/models/task.js @@ -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; diff --git a/backend/routes/tasks/core/builders.js b/backend/routes/tasks/core/builders.js index 7b965e2..254f5eb 100644 --- a/backend/routes/tasks/core/builders.js +++ b/backend/routes/tasks/core/builders.js @@ -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, diff --git a/backend/routes/tasks/core/serializers.js b/backend/routes/tasks/core/serializers.js index 9fc0ff2..382327f 100644 --- a/backend/routes/tasks/core/serializers.js +++ b/backend/routes/tasks/core/serializers.js @@ -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); diff --git a/backend/routes/tasks/index.js b/backend/routes/tasks/index.js index 006c5af..c1a82f6 100644 --- a/backend/routes/tasks/index.js +++ b/backend/routes/tasks/index.js @@ -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); diff --git a/backend/routes/tasks/operations/list.js b/backend/routes/tasks/operations/list.js index 276beae..42d8463 100644 --- a/backend/routes/tasks/operations/list.js +++ b/backend/routes/tasks/operations/list.js @@ -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( diff --git a/backend/routes/tasks/queries/query-builders.js b/backend/routes/tasks/queries/query-builders.js index d1a83ae..2e17a50 100644 --- a/backend/routes/tasks/queries/query-builders.js +++ b/backend/routes/tasks/queries/query-builders.js @@ -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 []; } diff --git a/backend/scripts/generate-today-instances.js b/backend/scripts/generate-today-instances.js deleted file mode 100644 index f779e02..0000000 --- a/backend/scripts/generate-today-instances.js +++ /dev/null @@ -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); -}); diff --git a/backend/services/recurringTaskService.js b/backend/services/recurringTaskService.js index fb75bd2..a31c209 100644 --- a/backend/services/recurringTaskService.js +++ b/backend/services/recurringTaskService.js @@ -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, diff --git a/backend/services/taskScheduler.js b/backend/services/taskScheduler.js index 4dac17a..fd7a932 100644 --- a/backend/services/taskScheduler.js +++ b/backend/services/taskScheduler.js @@ -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, diff --git a/backend/tests/integration/global-recurring-filter.test.js b/backend/tests/integration/global-recurring-filter.test.js deleted file mode 100644 index 802449e..0000000 --- a/backend/tests/integration/global-recurring-filter.test.js +++ /dev/null @@ -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'); - }); - }); -}); diff --git a/backend/tests/integration/recurring-project-change.test.js b/backend/tests/integration/recurring-project-change.test.js deleted file mode 100644 index 1f8187a..0000000 --- a/backend/tests/integration/recurring-project-change.test.js +++ /dev/null @@ -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); - }); - }); -}); diff --git a/backend/tests/integration/recurring-tasks.test.js b/backend/tests/integration/recurring-tasks.test.js index 843dee2..2ae1e02 100644 --- a/backend/tests/integration/recurring-tasks.test.js +++ b/backend/tests/integration/recurring-tasks.test.js @@ -1,23 +1,12 @@ const request = require('supertest'); const app = require('../../app'); -const { Task, User } = require('../../models'); +const { Task, RecurringCompletion, sequelize } = require('../../models'); const { createTestUser } = require('../helpers/testUtils'); +const { calculateNextDueDate } = require('../../services/recurringTaskService'); -describe('Recurring Tasks API', () => { +describe('Recurring Tasks', () => { let user, agent; - const toggleTaskCompletion = async (taskId) => { - const task = await Task.findByPk(taskId); - const newStatus = - task.status === Task.STATUS.DONE - ? task.note - ? Task.STATUS.IN_PROGRESS - : Task.STATUS.NOT_STARTED - : Task.STATUS.DONE; - - return agent.patch(`/api/task/${task.uid}`).send({ status: newStatus }); - }; - beforeEach(async () => { user = await createTestUser({ email: 'test@example.com', @@ -31,832 +20,1115 @@ describe('Recurring Tasks API', () => { }); }); - describe('POST /api/task - Creating recurring tasks', () => { - it('should create a daily recurring task', async () => { - const taskData = { - name: 'Daily Exercise', - recurrence_type: 'daily', - recurrence_interval: 1, - priority: 1, - completion_based: false, - }; + describe('Initial Due Date Calculation', () => { + describe('Daily Recurrence', () => { + it('should set correct due date for daily recurring task', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); - const response = await agent.post('/api/task').send(taskData); + const taskData = { + name: 'Daily Exercise', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today.toISOString().split('T')[0], + }; - expect(response.status).toBe(201); - expect(response.body.name).toBe('Daily Exercise'); - expect(response.body.recurrence_type).toBe('daily'); - expect(response.body.recurrence_interval).toBe(1); - expect(response.body.completion_based).toBe(false); + const response = await agent.post('/api/task').send(taskData); + + expect(response.status).toBe(201); + expect(response.body.recurrence_type).toBe('daily'); + expect(response.body.recurrence_interval).toBe(1); + + // Verify the task was created with the correct due date + const createdTask = await Task.findByPk(response.body.id); + expect(createdTask.due_date).toBeDefined(); + expect( + new Date(createdTask.due_date).toISOString().split('T')[0] + ).toBe(today.toISOString().split('T')[0]); + }); + + it('should handle daily recurrence with interval of 2', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const taskData = { + name: 'Every Other Day Task', + recurrence_type: 'daily', + recurrence_interval: 2, + due_date: today.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + + expect(response.status).toBe(201); + expect(response.body.recurrence_type).toBe('daily'); + expect(response.body.recurrence_interval).toBe(2); + + // Calculate next occurrence + const task = await Task.findByPk(response.body.id); + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); + const expectedDate = new Date(today); + expectedDate.setDate(expectedDate.getDate() + 2); + + expect(nextDate.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] + ); + }); + + it('should handle daily recurrence with interval of 7', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const taskData = { + name: 'Weekly via Daily', + recurrence_type: 'daily', + recurrence_interval: 7, + due_date: today.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + + expect(response.status).toBe(201); + + const task = await Task.findByPk(response.body.id); + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); + const expectedDate = new Date(today); + expectedDate.setDate(expectedDate.getDate() + 7); + + expect(nextDate.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] + ); + }); }); - it('should create a weekly recurring task with specific weekday', async () => { - const taskData = { - name: 'Weekly Team Meeting', - recurrence_type: 'weekly', - recurrence_interval: 1, - recurrence_weekday: 1, // Monday - priority: 2, - }; + describe('Weekly Recurrence', () => { + it('should set correct due date for weekly recurring task', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayWeekday = today.getDay(); - const response = await agent.post('/api/task').send(taskData); + const taskData = { + name: 'Weekly Meeting', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: todayWeekday, + due_date: today.toISOString().split('T')[0], + }; - expect(response.status).toBe(201); - expect(response.body.name).toBe('Weekly Team Meeting'); - expect(response.body.recurrence_type).toBe('weekly'); - expect(response.body.recurrence_weekday).toBe(1); + const response = await agent.post('/api/task').send(taskData); + + expect(response.status).toBe(201); + expect(response.body.recurrence_type).toBe('weekly'); + expect(response.body.recurrence_weekday).toBe(todayWeekday); + + const createdTask = await Task.findByPk(response.body.id); + expect(createdTask.due_date).toBeDefined(); + }); + + it('should calculate next week for weekly recurrence', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayWeekday = today.getDay(); + + const taskData = { + name: 'Weekly Review', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: todayWeekday, + due_date: today.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); + const expectedDate = new Date(today); + expectedDate.setDate(expectedDate.getDate() + 7); + + expect(nextDate.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] + ); + }); + + it('should handle bi-weekly recurrence', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayWeekday = today.getDay(); + + const taskData = { + name: 'Bi-weekly Task', + recurrence_type: 'weekly', + recurrence_interval: 2, + recurrence_weekday: todayWeekday, + due_date: today.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); + const expectedDate = new Date(today); + expectedDate.setDate(expectedDate.getDate() + 14); + + expect(nextDate.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] + ); + }); + + it('should handle weekly recurrence on a different weekday', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayWeekday = today.getDay(); + // Target a different weekday (e.g., if today is Monday (1), target Friday (5)) + const targetWeekday = (todayWeekday + 4) % 7; + + const taskData = { + name: 'Weekly on Different Day', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: targetWeekday, + due_date: today.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); + expect(nextDate.getDay()).toBe(targetWeekday); + }); }); - it('should create a monthly recurring task', async () => { - const taskData = { - name: 'Pay Rent', - recurrence_type: 'monthly', - recurrence_interval: 1, - recurrence_month_day: 1, - priority: 2, - }; + describe('Monthly Recurrence', () => { + it('should set correct due date for monthly recurring task', async () => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const dayOfMonth = today.getUTCDate(); - const response = await agent.post('/api/task').send(taskData); + const taskData = { + name: 'Monthly Report', + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: dayOfMonth, + due_date: today.toISOString().split('T')[0], + }; - expect(response.status).toBe(201); - expect(response.body.name).toBe('Pay Rent'); - expect(response.body.recurrence_type).toBe('monthly'); - expect(response.body.recurrence_month_day).toBe(1); + const response = await agent.post('/api/task').send(taskData); + + expect(response.status).toBe(201); + expect(response.body.recurrence_type).toBe('monthly'); + expect(response.body.recurrence_month_day).toBe(dayOfMonth); + + const createdTask = await Task.findByPk(response.body.id); + expect(createdTask.due_date).toBeDefined(); + }); + + it('should calculate next month for monthly recurrence', async () => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const dayOfMonth = 15; // Use a safe day that exists in all months + + const taskData = { + name: 'Monthly Bill', + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: dayOfMonth, + due_date: new Date( + Date.UTC( + today.getUTCFullYear(), + today.getUTCMonth(), + dayOfMonth + ) + ) + .toISOString() + .split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); + const expectedMonth = (today.getUTCMonth() + 1) % 12; + + expect(nextDate.getUTCDate()).toBe(dayOfMonth); + expect(nextDate.getUTCMonth()).toBe(expectedMonth); + }); + + it('should handle monthly recurrence on last day of month', async () => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + + const taskData = { + name: 'End of Month Task', + recurrence_type: 'monthly_last_day', + recurrence_interval: 1, + due_date: today.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); + + // Should be last day of next month + const expectedDate = new Date( + Date.UTC( + nextDate.getUTCFullYear(), + nextDate.getUTCMonth() + 1, + 0 + ) + ); + expect(nextDate.getUTCDate()).toBe(expectedDate.getUTCDate()); + }); + + it('should handle monthly recurrence when day does not exist in target month', async () => { + // Create a task for Jan 31 + const jan31 = new Date(Date.UTC(2024, 0, 31, 0, 0, 0, 0)); + + const taskData = { + name: 'Monthly on 31st', + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: 31, + due_date: jan31.toISOString().split('T')[0], + }; + + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + + // Next occurrence should be Feb 29 (if leap year) or Feb 28 + const nextDate = calculateNextDueDate(task, jan31); + + // February should cap at the last day of the month + expect(nextDate.getUTCMonth()).toBe(1); // February + expect(nextDate.getUTCDate()).toBeLessThanOrEqual(29); + }); }); - it('should create a monthly weekday recurring task', async () => { - const taskData = { - name: 'First Monday Meeting', - recurrence_type: 'monthly_weekday', - recurrence_interval: 1, - recurrence_weekday: 1, // Monday - recurrence_week_of_month: 1, // First week - priority: 1, - }; + describe('Monthly Weekday Recurrence', () => { + it('should handle first Monday of the month', async () => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); - const response = await agent.post('/api/task').send(taskData); + const taskData = { + name: 'First Monday Meeting', + recurrence_type: 'monthly_weekday', + recurrence_interval: 1, + recurrence_weekday: 1, // Monday + recurrence_week_of_month: 1, // First week + due_date: today.toISOString().split('T')[0], + }; - expect(response.status).toBe(201); - expect(response.body.name).toBe('First Monday Meeting'); - expect(response.body.recurrence_type).toBe('monthly_weekday'); - expect(response.body.recurrence_weekday).toBe(1); - expect(response.body.recurrence_week_of_month).toBe(1); - }); + const response = await agent.post('/api/task').send(taskData); + expect(response.status).toBe(201); - it('should create a monthly last day recurring task', async () => { - const taskData = { - name: 'Month-end Report', - recurrence_type: 'monthly_last_day', - recurrence_interval: 1, - priority: 2, - }; + const task = await Task.findByPk(response.body.id); + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); - const response = await agent.post('/api/task').send(taskData); + // Should be a Monday + expect(nextDate.getUTCDay()).toBe(1); + }); - expect(response.status).toBe(201); - expect(response.body.name).toBe('Month-end Report'); - expect(response.body.recurrence_type).toBe('monthly_last_day'); - }); + it('should handle third Friday of the month', async () => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); - it('should create a completion-based recurring task', async () => { - const taskData = { - name: 'Car Maintenance', - recurrence_type: 'monthly', - recurrence_interval: 3, - completion_based: true, - priority: 1, - }; + const taskData = { + name: 'Third Friday Task', + recurrence_type: 'monthly_weekday', + recurrence_interval: 1, + recurrence_weekday: 5, // Friday + recurrence_week_of_month: 3, // Third week + due_date: today.toISOString().split('T')[0], + }; - const response = await agent.post('/api/task').send(taskData); + const response = await agent.post('/api/task').send(taskData); + const task = await Task.findByPk(response.body.id); + const nextDate = calculateNextDueDate( + task, + new Date(task.due_date) + ); - expect(response.status).toBe(201); - expect(response.body.name).toBe('Car Maintenance'); - expect(response.body.completion_based).toBe(true); - }); - - it('should create recurring task with end date', async () => { - const endDate = new Date(); - endDate.setMonth(endDate.getMonth() + 6); - - const taskData = { - name: 'Temporary Recurring Task', - recurrence_type: 'weekly', - recurrence_interval: 2, - recurrence_end_date: endDate.toISOString().split('T')[0], - priority: 1, - }; - - const response = await agent.post('/api/task').send(taskData); - - expect(response.status).toBe(201); - expect(response.body.name).toBe('Temporary Recurring Task'); - expect(response.body.recurrence_end_date).toContain( - endDate.toISOString().split('T')[0] - ); - }); - - it('should default to none recurrence type if not specified', async () => { - const taskData = { - name: 'Regular Task', - priority: 1, - }; - - const response = await agent.post('/api/task').send(taskData); - - expect(response.status).toBe(201); - expect(response.body.recurrence_type).toBe('none'); + // Should be a Friday + expect(nextDate.getUTCDay()).toBe(5); + }); }); }); - describe('PATCH /api/task/:id - Updating recurring tasks', () => { - let task; + describe('Due Date Refresh on Completion', () => { + it('should advance due date when daily recurring task is completed', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); - beforeEach(async () => { - task = await Task.create({ - name: 'Test Recurring Task', - recurrence_type: 'daily', - recurrence_interval: 1, - user_id: user.id, - priority: 1, - }); - }); - - it('should update recurrence settings', async () => { - const updateData = { - recurrence_type: 'weekly', - recurrence_interval: 2, - recurrence_weekday: 5, // Friday - }; - - const response = await agent - .patch(`/api/task/${task.uid}`) - .send(updateData); - - expect(response.status).toBe(200); - expect(response.body.recurrence_type).toBe('weekly'); - expect(response.body.recurrence_interval).toBe(2); - expect(response.body.recurrence_weekday).toBe(5); - }); - - it('should update completion_based setting', async () => { - const updateData = { - completion_based: true, - }; - - const response = await agent - .patch(`/api/task/${task.uid}`) - .send(updateData); - - expect(response.status).toBe(200); - expect(response.body.completion_based).toBe(true); - }); - - it('should update recurrence end date', async () => { - const endDate = new Date(); - endDate.setFullYear(endDate.getFullYear() + 1); - - const updateData = { - recurrence_end_date: endDate.toISOString().split('T')[0], - }; - - const response = await agent - .patch(`/api/task/${task.uid}`) - .send(updateData); - - expect(response.status).toBe(200); - expect(response.body.recurrence_end_date).toContain( - endDate.toISOString().split('T')[0] - ); - }); - - it('should disable recurrence by setting type to none', async () => { - const updateData = { - recurrence_type: 'none', - }; - - const response = await agent - .patch(`/api/task/${task.uid}`) - .send(updateData); - - expect(response.status).toBe(200); - expect(response.body.recurrence_type).toBe('none'); - }); - }); - - describe('PATCH /api/task/:id - Updating parent recurrence from child task', () => { - let parentTask, childTask; - - beforeEach(async () => { - parentTask = await Task.create({ - name: 'Parent Recurring Task', - recurrence_type: 'daily', - recurrence_interval: 1, - user_id: user.id, - priority: 1, - }); - - childTask = await Task.create({ - name: 'Parent Recurring Task', - recurrence_type: 'none', - recurring_parent_id: parentTask.id, - user_id: user.id, - priority: 1, - due_date: new Date(), - }); - }); - - it('should update parent recurrence settings when update_parent_recurrence is true', async () => { - const updateData = { - recurrence_type: 'weekly', - recurrence_interval: 2, - recurrence_weekday: 3, - update_parent_recurrence: true, - }; - - const response = await agent - .patch(`/api/task/${childTask.uid}`) - .send(updateData); - - expect(response.status).toBe(200); - - // Check that parent task was 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(3); - }); - - it('should not update parent when update_parent_recurrence is false', async () => { - const originalParentType = parentTask.recurrence_type; - - const updateData = { - recurrence_type: 'weekly', - update_parent_recurrence: false, - }; - - const response = await agent - .patch(`/api/task/${childTask.uid}`) - .send(updateData); - - expect(response.status).toBe(200); - - // Check that parent task was not updated - const updatedParent = await Task.findByPk(parentTask.id); - expect(updatedParent.recurrence_type).toBe(originalParentType); - }); - - it('should not update parent when task has no recurring_parent_id', async () => { - const standaloneTask = await Task.create({ - name: 'Standalone Task', - recurrence_type: 'none', - user_id: user.id, - priority: 1, - }); - - const updateData = { - recurrence_type: 'weekly', - update_parent_recurrence: true, - }; - - const response = await agent - .patch(`/api/task/${standaloneTask.uid}`) - .send(updateData); - - expect(response.status).toBe(200); - expect(response.body.recurrence_type).toBe('weekly'); - }); - }); - - describe('PATCH /api/task/:id - Recurring task completion', () => { - it('should create next instance when completing a completion-based recurring task', async () => { - const recurringTask = await Task.create({ - name: 'Completion Based Task', - recurrence_type: 'daily', - recurrence_interval: 1, - completion_based: true, - user_id: user.id, - status: 0, // NOT_STARTED - }); - - const response = await toggleTaskCompletion(recurringTask.id); - - expect(response.status).toBe(200); - expect(response.body.status).toBe(2); // DONE - expect(response.body.next_task).toBeDefined(); - expect(response.body.next_task.name).toBe('Completion Based Task'); - expect(response.body.next_task.recurring_parent_id).toBe( - recurringTask.id - ); - }); - - it('should not create next instance for non-completion-based recurring tasks', async () => { - const recurringTask = await Task.create({ - name: 'Schedule Based Task', - recurrence_type: 'daily', - recurrence_interval: 1, - completion_based: false, - user_id: user.id, - status: 0, // NOT_STARTED - }); - - const response = await toggleTaskCompletion(recurringTask.id); - - expect(response.status).toBe(200); - expect(response.body.status).toBe(2); // DONE - expect(response.body.next_task).toBeUndefined(); - }); - - it('should not create next instance for non-recurring tasks', async () => { - const regularTask = await Task.create({ - name: 'Regular Task', - recurrence_type: 'none', - user_id: user.id, - status: 0, // NOT_STARTED - }); - - const response = await toggleTaskCompletion(regularTask.id); - - expect(response.status).toBe(200); - expect(response.body.status).toBe(2); // DONE - expect(response.body.next_task).toBeUndefined(); - }); - - it('should toggle completion back to not done', async () => { + // Create a daily recurring task const task = await Task.create({ - name: 'Test Task', - user_id: user.id, - status: 2, // DONE - }); - - const response = await toggleTaskCompletion(task.id); - - expect(response.status).toBe(200); - expect(response.body.status).toBe(0); // NOT_STARTED - }); - - it('should toggle to in_progress if task has a note', async () => { - const task = await Task.create({ - name: 'Test Task', - note: 'Some notes', - user_id: user.id, - status: 2, // DONE - }); - - const response = await toggleTaskCompletion(task.id); - - expect(response.status).toBe(200); - expect(response.body.status).toBe(1); // IN_PROGRESS - }); - }); - - describe('POST /api/tasks/generate-recurring', () => { - beforeEach(async () => { - const baseDate = new Date(); - baseDate.setDate(baseDate.getDate() - 30); // 30 days ago to ensure generation - - // Find next Monday for weekly task - const mondayDate = new Date(baseDate); - while (mondayDate.getDay() !== 1) { - mondayDate.setDate(mondayDate.getDate() + 1); - } - - // Create some recurring tasks for testing - await Task.create({ name: 'Daily Task', recurrence_type: 'daily', recurrence_interval: 1, + due_date: today, user_id: user.id, - due_date: baseDate, - last_generated_date: baseDate, + status: Task.STATUS.NOT_STARTED, }); - await Task.create({ + const originalDueDate = new Date(task.due_date); + + // Mark the task as completed + const response = await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + expect(response.status).toBe(200); + + // Reload the task + await task.reload(); + + // Task should be reset to NOT_STARTED + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + expect(task.completed_at).toBeNull(); + + // Due date should be advanced by 1 day + const newDueDate = new Date(task.due_date); + const expectedDate = new Date(originalDueDate); + expectedDate.setDate(expectedDate.getDate() + 1); + + expect(newDueDate.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] + ); + + // Verify RecurringCompletion was created + const completions = await RecurringCompletion.findAll({ + where: { task_id: task.id }, + }); + expect(completions.length).toBe(1); + expect(completions[0].original_due_date).toBeDefined(); + }); + + it('should advance due date when weekly recurring task is completed', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const todayWeekday = today.getDay(); + + const task = await Task.create({ name: 'Weekly Task', recurrence_type: 'weekly', recurrence_interval: 1, - recurrence_weekday: 1, + recurrence_weekday: todayWeekday, + due_date: today, user_id: user.id, - due_date: mondayDate, // Monday - last_generated_date: mondayDate, + status: Task.STATUS.NOT_STARTED, }); - }); - it('should generate recurring task instances', async () => { - const response = await agent.post('/api/tasks/generate-recurring'); + const originalDueDate = new Date(task.due_date); - expect(response.status).toBe(200); - expect(response.body.message).toMatch( - /Generated \d+ recurring tasks/ + // Complete the task + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + // Due date should be advanced by 1 week + const newDueDate = new Date(task.due_date); + const expectedDate = new Date(originalDueDate); + expectedDate.setDate(expectedDate.getDate() + 7); + + expect(newDueDate.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] ); - expect(response.body.tasks).toBeDefined(); - expect(Array.isArray(response.body.tasks)).toBe(true); + expect(task.status).toBe(Task.STATUS.NOT_STARTED); }); - it('should require authentication', async () => { - const response = await request(app).post( - '/api/tasks/generate-recurring' + it('should advance due date when monthly recurring task is completed', async () => { + const today = new Date(); + today.setUTCHours(0, 0, 0, 0); + const dayOfMonth = 15; // Use a safe day + + const startDate = new Date( + Date.UTC( + today.getUTCFullYear(), + today.getUTCMonth(), + dayOfMonth + ) ); - expect(response.status).toBe(401); - expect(response.body.error).toBe('Authentication required'); - }); - - it('should handle errors gracefully', async () => { - // Mock console.error to suppress expected error log in test output - const originalConsoleError = console.error; - console.error = jest.fn(); - - // Create invalid recurring task to trigger error - await Task.create({ - name: 'Invalid Task', - recurrence_type: 'invalid_type', + const task = await Task.create({ + name: 'Monthly Task', + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: dayOfMonth, + due_date: startDate, user_id: user.id, + status: Task.STATUS.NOT_STARTED, }); - const response = await agent.post('/api/tasks/generate-recurring'); + const originalMonth = new Date(task.due_date).getUTCMonth(); - // Should still return success even if some tasks fail - expect(response.status).toBe(200); + // Complete the task + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); - // Restore original console.error - console.error = originalConsoleError; + await task.reload(); + + // Due date should be in the next month + const newDueDate = new Date(task.due_date); + const expectedMonth = (originalMonth + 1) % 12; + + expect(newDueDate.getUTCMonth()).toBe(expectedMonth); + expect(newDueDate.getUTCDate()).toBe(dayOfMonth); + expect(task.status).toBe(Task.STATUS.NOT_STARTED); }); - }); - describe('GET /api/tasks - Filtering recurring tasks', () => { - beforeEach(async () => { - // Create a mix of regular and recurring tasks - await Task.create({ - name: 'Regular Task', - recurrence_type: 'none', - user_id: user.id, - status: 0, - }); + it('should track multiple completions in RecurringCompletion table', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); - const parentTask = await Task.create({ - name: 'Recurring Parent', + const task = await Task.create({ + name: 'Daily Task', recurrence_type: 'daily', recurrence_interval: 1, + due_date: today, user_id: user.id, - status: 0, + status: Task.STATUS.NOT_STARTED, }); - await Task.create({ - name: 'Recurring Child', - recurrence_type: 'none', - recurring_parent_id: parentTask.id, - user_id: user.id, - status: 0, + // Complete the task three times + for (let i = 0; i < 3; i++) { + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + } + + // Verify three completions were recorded + const completions = await RecurringCompletion.findAll({ + where: { task_id: task.id }, + order: [['completed_at', 'ASC']], }); - }); - it('should return tasks excluding recurring instances', async () => { - const response = await agent.get('/api/tasks'); + expect(completions.length).toBe(3); - expect(response.status).toBe(200); - expect(response.body.tasks).toBeDefined(); - expect(response.body.tasks.length).toBe(2); - - const taskNames = response.body.tasks.map((t) => t.name); - expect(taskNames).toContain('Regular Task'); - expect(taskNames).toContain('Daily'); // Recurring parent shows as "Daily" - expect(taskNames).not.toContain('Recurring Child'); // Instance should be filtered out - }); - - it('should return task metrics excluding recurring instances', async () => { - const response = await agent.get('/api/tasks/metrics'); - - expect(response.status).toBe(200); - expect(response.body.total_open_tasks).toBeDefined(); - expect(response.body.total_open_tasks).toBe(2); + // Verify each completion has the correct original_due_date + for (let i = 0; i < 3; i++) { + expect(completions[i].original_due_date).toBeDefined(); + expect(completions[i].skipped).toBe(false); + } }); }); - describe('GET /api/task/:id - Retrieving individual recurring tasks', () => { - let recurringTask; + describe('Completion-Based Recurrence', () => { + it('should use completion date when completion_based is true', async () => { + const threeDaysAgo = new Date(); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + threeDaysAgo.setHours(0, 0, 0, 0); - beforeEach(async () => { - recurringTask = await Task.create({ - name: 'Test Recurring Task', - recurrence_type: 'weekly', - recurrence_interval: 2, - recurrence_weekday: 1, + // Create a task due 3 days ago with completion-based recurrence + const task = await Task.create({ + name: 'Completion Based Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: threeDaysAgo, completion_based: true, user_id: user.id, + status: Task.STATUS.NOT_STARTED, }); + + // Complete it today + const completionTime = new Date(); + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + // Next due date should be tomorrow (completion date + interval) + // not 2 days ago (original due date + interval) + const newDueDate = new Date(task.due_date); + const tomorrow = new Date(completionTime); + tomorrow.setDate(tomorrow.getDate() + 1); + tomorrow.setHours(0, 0, 0, 0); + + const dayDiff = Math.round( + (newDueDate - completionTime) / (1000 * 60 * 60 * 24) + ); + + // Should be approximately 1 day from completion + expect(dayDiff).toBeGreaterThanOrEqual(0); + expect(dayDiff).toBeLessThanOrEqual(1); }); - it('should return recurring task with all recurrence fields', async () => { - const response = await agent.get(`/api/task/${recurringTask.uid}`); + it('should use original due date when completion_based is false', async () => { + const threeDaysAgo = new Date(); + threeDaysAgo.setDate(threeDaysAgo.getDate() - 3); + threeDaysAgo.setHours(0, 0, 0, 0); - expect(response.status).toBe(200); - expect(response.body.name).toBe('Test Recurring Task'); - expect(response.body.recurrence_type).toBe('weekly'); - expect(response.body.recurrence_interval).toBe(2); - expect(response.body.recurrence_weekday).toBe(1); - expect(response.body.completion_based).toBe(true); - }); - }); - - describe('DELETE /api/task/:id - Deleting recurring tasks', () => { - let parentTask, childTask; - - beforeEach(async () => { - parentTask = await Task.create({ - name: 'Parent Recurring Task', + const task = await Task.create({ + name: 'Date Based Task', recurrence_type: 'daily', recurrence_interval: 1, + due_date: threeDaysAgo, + completion_based: false, user_id: user.id, + status: Task.STATUS.NOT_STARTED, }); - childTask = await Task.create({ - name: 'Child Task Instance', - recurrence_type: 'none', - recurring_parent_id: parentTask.id, + const originalDueDate = new Date(task.due_date); + + // Complete it today + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + // Next due date should be 2 days ago (original due date + 1 day) + const newDueDate = new Date(task.due_date); + const expectedDate = new Date(originalDueDate); + expectedDate.setDate(expectedDate.getDate() + 1); + + expect(newDueDate.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] + ); + }); + + it('should use completion date for early completion when completion_based is true', async () => { + const twoDaysFromNow = new Date(); + twoDaysFromNow.setDate(twoDaysFromNow.getDate() + 2); + twoDaysFromNow.setHours(0, 0, 0, 0); + + const task = await Task.create({ + name: 'Early Completion Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: twoDaysFromNow, + completion_based: true, user_id: user.id, + status: Task.STATUS.NOT_STARTED, }); + + // Complete it today (2 days early) + const completionTime = new Date(); + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + // Next due date should be tomorrow (today + 1 day) + // not 3 days from now (original due date + 1 day) + const newDueDate = new Date(task.due_date); + const dayDiff = Math.round( + (newDueDate - completionTime) / (1000 * 60 * 60 * 24) + ); + + // Should be approximately 1 day from completion (today) + expect(dayDiff).toBeGreaterThanOrEqual(0); + expect(dayDiff).toBeLessThanOrEqual(1); + + // Verify it's NOT 3 days from now + const threeDaysFromNow = new Date(); + threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3); + expect(newDueDate.toISOString().split('T')[0]).not.toBe( + threeDaysFromNow.toISOString().split('T')[0] + ); }); - it('should smart delete recurring parent task - remove future instances, orphan past ones', async () => { - const response = await agent.delete(`/api/task/${parentTask.uid}`); + it('should work with weekly completion_based recurrence', async () => { + const lastWeek = new Date(); + lastWeek.setDate(lastWeek.getDate() - 7); + lastWeek.setHours(0, 0, 0, 0); + const lastWeekWeekday = lastWeek.getDay(); - expect(response.status).toBe(200); - expect(response.body.message).toBe('Task successfully deleted'); + const task = await Task.create({ + name: 'Weekly Completion Based', + recurrence_type: 'weekly', + recurrence_interval: 1, + recurrence_weekday: lastWeekWeekday, + due_date: lastWeek, + completion_based: true, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); - // Verify parent task is deleted - const deletedParent = await Task.findByPk(parentTask.id); - expect(deletedParent).toBeNull(); + // Complete it today + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); - // Since childTask is NOT_STARTED with no due date, it should be considered future and deleted - const deletedChild = await Task.findByPk(childTask.id); - expect(deletedChild).toBeNull(); + await task.reload(); + + // Next due date should be approximately 7 days from today + // not 7 days from last week (which would be today) + const newDueDate = new Date(task.due_date); + const today = new Date(); + const dayDiff = Math.round( + (newDueDate - today) / (1000 * 60 * 60 * 24) + ); + + // Should be around 6-7 days from now + expect(dayDiff).toBeGreaterThanOrEqual(6); + expect(dayDiff).toBeLessThanOrEqual(8); }); - it('should delete recurring child task', async () => { - const response = await agent.delete(`/api/task/${childTask.uid}`); + it('should work with monthly completion_based recurrence', async () => { + const lastMonth = new Date(); + lastMonth.setMonth(lastMonth.getMonth() - 1); + lastMonth.setUTCHours(0, 0, 0, 0); + const dayOfMonth = 15; - expect(response.status).toBe(200); - expect(response.body.message).toBe('Task successfully deleted'); + const task = await Task.create({ + name: 'Monthly Completion Based', + recurrence_type: 'monthly', + recurrence_interval: 1, + recurrence_month_day: dayOfMonth, + due_date: new Date( + Date.UTC( + lastMonth.getUTCFullYear(), + lastMonth.getUTCMonth(), + dayOfMonth + ) + ), + completion_based: true, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); - // Verify task is deleted - const deletedTask = await Task.findByPk(childTask.id); - expect(deletedTask).toBeNull(); + // Complete it today + const completionMonth = new Date().getUTCMonth(); + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); - // Verify parent still exists - const parentStillExists = await Task.findByPk(parentTask.id); - expect(parentStillExists).not.toBeNull(); + await task.reload(); + + // Next due date should be approximately 1 month from today + // not 1 month from last month (which would be this month) + const newDueDate = new Date(task.due_date); + const expectedMonth = (completionMonth + 1) % 12; + + // Should be next month + expect(newDueDate.getUTCMonth()).toBe(expectedMonth); + }); + + it('should handle interval > 1 with completion_based', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const task = await Task.create({ + name: 'Every 3 Days Completion Based', + recurrence_type: 'daily', + recurrence_interval: 3, + due_date: today, + completion_based: true, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + const completionTime = new Date(); + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + // Next due date should be 3 days from completion + const newDueDate = new Date(task.due_date); + const dayDiff = Math.round( + (newDueDate - completionTime) / (1000 * 60 * 60 * 24) + ); + + // Should be approximately 3 days from completion + expect(dayDiff).toBeGreaterThanOrEqual(2); + expect(dayDiff).toBeLessThanOrEqual(3); + }); + + it('should respect updated completion_based flag', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + // Create task with completion_based = false + const task = await Task.create({ + name: 'Toggle Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today, + completion_based: false, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Update to completion_based = true + await agent.patch(`/api/task/${task.uid}`).send({ + completion_based: true, + }); + + await task.reload(); + expect(task.completion_based).toBe(true); + + // Now complete it - should use completion-based logic + const completionTime = new Date(); + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + // Should advance from completion time, not original due date + const newDueDate = new Date(task.due_date); + const dayDiff = Math.round( + (newDueDate - completionTime) / (1000 * 60 * 60 * 24) + ); + + expect(dayDiff).toBeGreaterThanOrEqual(0); + expect(dayDiff).toBeLessThanOrEqual(1); + }); + + it('should handle multiple rapid completions with completion_based', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const task = await Task.create({ + name: 'Rapid Completions', + recurrence_type: 'daily', + recurrence_interval: 2, + due_date: today, + completion_based: true, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // First completion + const firstCompletionTime = new Date(); + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + const firstNewDueDate = new Date(task.due_date); + + // Should be approximately 2 days from completion + const firstDayDiff = Math.round( + (firstNewDueDate - firstCompletionTime) / (1000 * 60 * 60 * 24) + ); + expect(firstDayDiff).toBeGreaterThanOrEqual(1); + expect(firstDayDiff).toBeLessThanOrEqual(2); + + // Second completion immediately after first + const secondCompletionTime = new Date(); + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + const secondNewDueDate = new Date(task.due_date); + + // Should be approximately 2 days from second completion + // which means it should be later than the first new due date + expect(secondNewDueDate.getTime()).toBeGreaterThan( + firstNewDueDate.getTime() + ); + + const secondDayDiff = Math.round( + (secondNewDueDate - secondCompletionTime) / + (1000 * 60 * 60 * 24) + ); + expect(secondDayDiff).toBeGreaterThanOrEqual(1); + expect(secondDayDiff).toBeLessThanOrEqual(2); + + // Verify both completions were recorded + const completions = await RecurringCompletion.findAll({ + where: { task_id: task.id }, + }); + expect(completions.length).toBe(2); }); }); - describe('GET /api/tasks?type=today - Today view filtering and naming', () => { - let parentTask, childTask, regularTask; + describe('Recurrence End Date', () => { + it('should stop recurring when end date is reached', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); + tomorrow.setDate(tomorrow.getDate() + 1); + const dayAfterTomorrow = new Date(today); + dayAfterTomorrow.setDate(dayAfterTomorrow.getDate() + 2); + const threeDaysFromNow = new Date(today); + threeDaysFromNow.setDate(threeDaysFromNow.getDate() + 3); - beforeEach(async () => { - // Create a regular non-recurring task - regularTask = await Task.create({ + // Create a daily task due today with end date 3 days from now + // Note: end date is exclusive (uses <, not <=) + // So this task can recur on: today (day 0), tomorrow (day 1), day 2 + // But NOT on day 3 (the end date itself) + const task = await Task.create({ + name: 'Limited Recurring Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today, + recurrence_end_date: threeDaysFromNow, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Complete the task (first completion) - should advance to tomorrow (day 1) + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + let newDueDate = new Date(task.due_date); + expect(newDueDate.toISOString().split('T')[0]).toBe( + tomorrow.toISOString().split('T')[0] + ); + + // Complete again - should advance to day after tomorrow (day 2, last valid occurrence) + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + newDueDate = new Date(task.due_date); + expect(newDueDate.toISOString().split('T')[0]).toBe( + dayAfterTomorrow.toISOString().split('T')[0] + ); + + // Complete one more time - this time it should stop recurring + // because the next occurrence would be day 3 (threeDaysFromNow) which is >= end date + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + + await task.reload(); + + // Task should remain completed since next occurrence would be on or past end date + expect(task.status).toBe(Task.STATUS.DONE); + expect(task.completed_at).not.toBeNull(); + }); + + it('should allow recurring tasks without end date to continue indefinitely', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const task = await Task.create({ + name: 'Infinite Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today, + recurrence_end_date: null, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Complete multiple times + for (let i = 0; i < 5; i++) { + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + } + + // Verify all completions were recorded + const completions = await RecurringCompletion.findAll({ + where: { task_id: task.id }, + }); + expect(completions.length).toBe(5); + }); + }); + + describe('Edge Cases', () => { + it('should handle completing a task multiple times in quick succession', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const task = await Task.create({ + name: 'Quick Complete Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + // Try to complete it twice rapidly + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + const dueDateAfterFirst = new Date(task.due_date); + + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + const dueDateAfterSecond = new Date(task.due_date); + + // Both completions should have been recorded + const completions = await RecurringCompletion.findAll({ + where: { task_id: task.id }, + }); + expect(completions.length).toBe(2); + + // Second due date should be one day after first + const expectedDate = new Date(dueDateAfterFirst); + expectedDate.setDate(expectedDate.getDate() + 1); + expect(dueDateAfterSecond.toISOString().split('T')[0]).toBe( + expectedDate.toISOString().split('T')[0] + ); + }); + + it('should handle uncompleting a recurring task', async () => { + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const task = await Task.create({ + name: 'Uncomplete Task', + recurrence_type: 'daily', + recurrence_interval: 1, + due_date: today, + user_id: user.id, + status: Task.STATUS.NOT_STARTED, + }); + + const originalDueDate = new Date(task.due_date); + + // Complete the task + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + const advancedDueDate = new Date(task.due_date); + + // Due date should have advanced + expect(advancedDueDate.getTime()).toBeGreaterThan( + originalDueDate.getTime() + ); + + // Change status to in_progress (not completed) + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.IN_PROGRESS }); + await task.reload(); + + // Due date should remain the same + expect(new Date(task.due_date).getTime()).toBe( + advancedDueDate.getTime() + ); + expect(task.status).toBe(Task.STATUS.IN_PROGRESS); + }); + + it('should not create recurring completion for non-recurring tasks', async () => { + const task = await Task.create({ name: 'Regular Task', recurrence_type: 'none', user_id: user.id, - status: 0, - today: true, + status: Task.STATUS.NOT_STARTED, }); - // Create a recurring parent task (template) - parentTask = await Task.create({ - name: 'Take vitamins', + // Complete the task + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); + + // Should stay completed + expect(task.status).toBe(Task.STATUS.DONE); + expect(task.completed_at).not.toBeNull(); + + // No recurring completion should be created + const completions = await RecurringCompletion.findAll({ + where: { task_id: task.id }, + }); + expect(completions.length).toBe(0); + }); + + it('should handle tasks without a due date', async () => { + const task = await Task.create({ + name: 'No Due Date Recurring', recurrence_type: 'daily', recurrence_interval: 1, + due_date: null, user_id: user.id, - status: 0, - today: true, + status: Task.STATUS.NOT_STARTED, }); - // Create a recurring child task (instance) - childTask = await Task.create({ - name: 'Take vitamins', - recurrence_type: 'none', - recurring_parent_id: parentTask.id, - user_id: user.id, - status: 0, - today: true, - due_date: new Date(), - }); - }); + // Complete the task + await agent + .patch(`/api/task/${task.uid}`) + .send({ status: Task.STATUS.DONE }); + await task.reload(); - it('should include recurring task instances in type=today API response', async () => { - const response = await agent.get('/api/tasks?type=today'); + // Should calculate next due date from completion time + expect(task.status).toBe(Task.STATUS.NOT_STARTED); + expect(task.due_date).not.toBeNull(); - expect(response.status).toBe(200); - expect(response.body.tasks).toBeDefined(); - - // Should return at least the regular task + recurring instance - // Parent tasks should NOT appear in today view - expect(response.body.tasks.length).toBeGreaterThanOrEqual(2); - - const taskIds = response.body.tasks.map((t) => t.id); - expect(taskIds).toContain(regularTask.id); - expect(taskIds).not.toContain(parentTask.id); // Parent should NOT be included - expect(taskIds).toContain(childTask.id); // Instance should be included - }); - - it('should preserve original names for recurring task instances in type=today API response', async () => { - const response = await agent.get('/api/tasks?type=today'); - - expect(response.status).toBe(200); - expect(response.body.tasks).toBeDefined(); - - // Find the recurring task instance in the response - const recurringInstance = response.body.tasks.find( - (t) => t.id === childTask.id - ); - expect(recurringInstance).toBeDefined(); - - // Instances should show original name, not "Daily" - expect(recurringInstance.name).toBe('Take vitamins'); - expect(recurringInstance.original_name).toBe('Take vitamins'); - expect(recurringInstance.name).not.toBe('Daily'); - }); - - it('should show generic names for non-today API calls (backward compatibility)', async () => { - const response = await agent.get('/api/tasks'); // No type=today - - expect(response.status).toBe(200); - expect(response.body.tasks).toBeDefined(); - - // Find the recurring task in the response - const recurringTask = response.body.tasks.find( - (t) => t.id === parentTask.id - ); - expect(recurringTask).toBeDefined(); - - // Should show generic recurrence name for backward compatibility - expect(recurringTask.name).toBe('Daily'); - expect(recurringTask.original_name).toBe('Take vitamins'); - }); - }); - - describe('Recurring tasks with subtasks', () => { - it('should copy subtasks when generating recurring task instances', async () => { - const recurringTaskService = require('../../services/recurringTaskService'); - - // Create a recurring task with subtasks - const taskData = { - name: 'Weekly grocery shopping', - recurrence_type: 'daily', - recurrence_interval: 1, - priority: 1, - completion_based: false, - subtasks: [ - { name: 'Buy milk', priority: 0 }, - { name: 'Buy bread', priority: 1 }, - { name: 'Buy eggs', priority: 0 }, - ], - }; - - const createResponse = await agent.post('/api/task').send(taskData); - expect(createResponse.status).toBe(201); - - const recurringTaskId = createResponse.body.id; - - // Verify subtasks were created - const subtasksResponse = await agent.get( - `/api/task/${recurringTaskId}/subtasks` - ); - expect(subtasksResponse.status).toBe(200); - expect(subtasksResponse.body.length).toBe(3); - - // Generate recurring task instances - const tomorrow = new Date(); + const dueDate = new Date(task.due_date); + const today = new Date(); + today.setHours(0, 0, 0, 0); + const tomorrow = new Date(today); tomorrow.setDate(tomorrow.getDate() + 1); - await recurringTaskService.generateRecurringTasks(user.id, 2); - - // Find the generated instance - const instances = await Task.findAll({ - where: { - user_id: user.id, - recurring_parent_id: recurringTaskId, - }, - }); - - expect(instances.length).toBeGreaterThan(0); - - const firstInstance = instances[0]; - - // Check if subtasks were copied to the instance - const instanceSubtasksResponse = await agent.get( - `/api/task/${firstInstance.id}/subtasks` + // Due date should be approximately today or tomorrow + expect(dueDate.getTime()).toBeGreaterThanOrEqual(today.getTime()); + expect(dueDate.getTime()).toBeLessThan( + tomorrow.getTime() + 24 * 60 * 60 * 1000 ); - expect(instanceSubtasksResponse.status).toBe(200); - expect(instanceSubtasksResponse.body.length).toBe(3); - - // Verify subtask names match - const subtaskNames = instanceSubtasksResponse.body.map( - (s) => s.name - ); - expect(subtaskNames).toContain('Buy milk'); - expect(subtaskNames).toContain('Buy bread'); - expect(subtaskNames).toContain('Buy eggs'); - - // Verify all subtasks are in NOT_STARTED status - instanceSubtasksResponse.body.forEach((subtask) => { - expect(subtask.status).toBe(Task.STATUS.NOT_STARTED); - }); }); }); - describe('Recurring tasks in Today view', () => { - it('should show recurring task instances in type=today API response', async () => { - const recurringTaskService = require('../../services/recurringTaskService'); - - // Create a recurring daily task with due date today - const today = new Date(); - today.setHours(12, 0, 0, 0); - - const taskData = { - name: 'Daily standup meeting', - recurrence_type: 'daily', - recurrence_interval: 1, - due_date: today.toISOString(), - priority: 1, - completion_based: false, - }; - - const createResponse = await agent.post('/api/task').send(taskData); - expect(createResponse.status).toBe(201); - - const recurringTaskId = createResponse.body.id; - - // Generate recurring task instances - await recurringTaskService.generateRecurringTasks(user.id, 2); - - // Verify instances were created - const instances = await Task.findAll({ - where: { - user_id: user.id, - recurring_parent_id: recurringTaskId, - }, - }); - - expect(instances.length).toBeGreaterThan(0); - - // Fetch tasks with type=today - const todayResponse = await agent.get('/api/tasks?type=today'); - expect(todayResponse.status).toBe(200); - - // Find the recurring task instance in the today response - const todayTasks = todayResponse.body.tasks; - - // Check if we have the recurring instance (but not the parent) - const recurringTasksInToday = todayTasks.filter( - (task) => task.recurring_parent_id === recurringTaskId - ); - - // Should find at least one instance (the one due today) - expect(recurringTasksInToday.length).toBeGreaterThan(0); - - // Verify at least one task with this name appears - const taskWithName = todayTasks.find( - (task) => task.name === 'Daily standup meeting' - ); - expect(taskWithName).toBeDefined(); - expect(taskWithName.recurring_parent_id).toBe(recurringTaskId); + describe('Service Function Tests', () => { + it('calculateNextDueDate should return null for invalid inputs', () => { + expect(calculateNextDueDate(null, new Date())).toBeNull(); + expect( + calculateNextDueDate({ recurrence_type: null }, new Date()) + ).toBeNull(); + expect( + calculateNextDueDate({ recurrence_type: 'daily' }, null) + ).toBeNull(); + expect( + calculateNextDueDate( + { recurrence_type: 'daily' }, + new Date('invalid') + ) + ).toBeNull(); }); - it('should include recurring_parent_uid in serialized task instances', async () => { - const today = new Date(); - const taskResponse = await agent.post('/api/task').send({ - name: 'Recurring parent test', - recurrence_type: 'daily', - recurrence_interval: 1, - due_date: today.toISOString().split('T')[0], - }); - - expect(taskResponse.status).toBe(201); - const recurringTask = taskResponse.body; - - await agent.post('/api/tasks/generate-recurring'); - - // Find the generated instance - const generatedInstance = await Task.findOne({ - where: { - user_id: user.id, - recurring_parent_id: recurringTask.id, - }, - }); - - expect(generatedInstance).toBeDefined(); - - const response = await agent.get('/api/tasks?type=today'); - expect(response.status).toBe(200); - - const instance = response.body.tasks.find( - (task) => task.recurring_parent_id === recurringTask.id + it('calculateNextDueDate should handle unknown recurrence types', () => { + const result = calculateNextDueDate( + { recurrence_type: 'unknown_type' }, + new Date() ); - - expect(instance).toBeDefined(); - expect(instance.recurring_parent_uid).toBeDefined(); - expect(instance.recurring_parent_uid).toBe(recurringTask.uid); + expect(result).toBeNull(); }); }); }); diff --git a/backend/tests/integration/smart-recurrence-update.test.js b/backend/tests/integration/smart-recurrence-update.test.js deleted file mode 100644 index f2dbb56..0000000 --- a/backend/tests/integration/smart-recurrence-update.test.js +++ /dev/null @@ -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(); - }); -}); diff --git a/backend/tests/unit/models/task.test.js b/backend/tests/unit/models/task.test.js index ea89688..69faf01 100644 --- a/backend/tests/unit/models/task.test.js +++ b/backend/tests/unit/models/task.test.js @@ -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(); }); diff --git a/backend/tests/unit/services/parentChildRelationship.test.js b/backend/tests/unit/services/parentChildRelationship.test.js deleted file mode 100644 index 7d49c39..0000000 --- a/backend/tests/unit/services/parentChildRelationship.test.js +++ /dev/null @@ -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(); - }); - }); -}); diff --git a/backend/tests/unit/services/recurringTaskService.test.js b/backend/tests/unit/services/recurringTaskService.test.js deleted file mode 100644 index 45e095f..0000000 --- a/backend/tests/unit/services/recurringTaskService.test.js +++ /dev/null @@ -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' - ); - }); - }); -}); diff --git a/backend/utils/migration-utils.js b/backend/utils/migration-utils.js index e2fe834..a56593e 100644 --- a/backend/utils/migration-utils.js +++ b/backend/utils/migration-utils.js @@ -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) { diff --git a/frontend/components/Sidebar/SidebarNav.tsx b/frontend/components/Sidebar/SidebarNav.tsx index 44efa7b..f15b974 100644 --- a/frontend/components/Sidebar/SidebarNav.tsx +++ b/frontend/components/Sidebar/SidebarNav.tsx @@ -26,10 +26,8 @@ const SidebarNav: React.FC = ({ 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 = ({ ]; 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 = ({ : '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 = ({ : '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 = ({ )} {link.path === '/tasks?status=active' && ( - + )} diff --git a/frontend/components/Task/TaskDetails.tsx b/frontend/components/Task/TaskDetails.tsx index 3224dc0..d504fe7 100644 --- a/frontend/components/Task/TaskDetails.tsx +++ b/frontend/components/Task/TaskDetails.tsx @@ -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(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' diff --git a/frontend/components/Task/TaskHeader.tsx b/frontend/components/Task/TaskHeader.tsx index dc0aa6b..4fb7766 100644 --- a/frontend/components/Task/TaskHeader.tsx +++ b/frontend/components/Task/TaskHeader.tsx @@ -219,7 +219,7 @@ const TaskHeader: React.FC = ({
{/* Full width title that wraps */}
- + {task.original_name || task.name}
@@ -306,7 +306,7 @@ const TaskHeader: React.FC = ({
) : (
- + {task.original_name || task.name}
@@ -581,7 +581,7 @@ const TaskHeader: React.FC = ({ {/* Task content - full width */}
{/* Task Title */} -
+
{task.original_name || task.name} diff --git a/frontend/components/Task/TaskItem.tsx b/frontend/components/Task/TaskItem.tsx index 8aa399d..cd0e709 100644 --- a/frontend/components/Task/TaskItem.tsx +++ b/frontend/components/Task/TaskItem.tsx @@ -99,7 +99,7 @@ const SubtasksDisplay: React.FC = ({ />
str.charAt(0).toUpperCase() + str.slice(1); -// Helper function to get search placeholder by language const getSearchPlaceholder = (language: string): string => { const placeholders: Record = { en: 'Search tasks...', @@ -54,13 +53,12 @@ const Tasks: React.FC = () => { const [dropdownOpen, setDropdownOpen] = useState(false); const [orderBy, setOrderBy] = useState('created_at:desc'); const [taskSearchQuery, setTaskSearchQuery] = useState(''); - 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) => { 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 = ( {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 => { 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 && ( -
+
+ {!isUpcomingView && (
{t('tasks.groupBy', 'Group by')} @@ -707,193 +675,183 @@ const Tasks: React.FC = () => { )}
-
-
- {t('tasks.show', 'Show')} -
-
- {[ - { - 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 ( - - ); - })} -
+ )} +
+
+ {t('tasks.show', 'Show')}
-
-
- {t( - 'tasks.direction', - 'Direction' - )} -
-
- {[ - { - 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 ( - - ); - })} -
+ 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' + }`} + > + {opt.label} + {isActive && ( + + )} + + ); + })}
- ) +
+
+ {t('tasks.direction', 'Direction')} +
+
+ {[ + { + 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 ( + + ); + })} +
+
+
} />
@@ -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} /> diff --git a/public/locales/en/translation.json b/public/locales/en/translation.json index ae7a4da..8b99a0d 100644 --- a/public/locales/en/translation.json +++ b/public/locales/en/translation.json @@ -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",