Fix recurring structure (#646)
* Refactor recurring * fixup! Refactor recurring * Add after completion tests * fixup! Add after completion tests * fixup! fixup! Add after completion tests
This commit is contained in:
parent
e75a6e290e
commit
cd6b810b08
32 changed files with 1957 additions and 3552 deletions
|
|
@ -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
|
||||
);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
},
|
||||
},
|
||||
]);
|
||||
},
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
47
backend/models/recurringCompletion.js
Normal file
47
backend/models/recurringCompletion.js
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
const { DataTypes } = require('sequelize');
|
||||
|
||||
module.exports = (sequelize) => {
|
||||
const RecurringCompletion = sequelize.define(
|
||||
'RecurringCompletion',
|
||||
{
|
||||
id: {
|
||||
type: DataTypes.INTEGER,
|
||||
primaryKey: true,
|
||||
autoIncrement: true,
|
||||
},
|
||||
task_id: {
|
||||
type: DataTypes.INTEGER,
|
||||
allowNull: false,
|
||||
references: {
|
||||
model: 'tasks',
|
||||
key: 'id',
|
||||
},
|
||||
},
|
||||
completed_at: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
original_due_date: {
|
||||
type: DataTypes.DATE,
|
||||
allowNull: false,
|
||||
},
|
||||
skipped: {
|
||||
type: DataTypes.BOOLEAN,
|
||||
defaultValue: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
tableName: 'recurring_completions',
|
||||
timestamps: false,
|
||||
}
|
||||
);
|
||||
|
||||
RecurringCompletion.associate = function (models) {
|
||||
RecurringCompletion.belongsTo(models.Task, {
|
||||
foreignKey: 'task_id',
|
||||
as: 'Task',
|
||||
});
|
||||
};
|
||||
|
||||
return RecurringCompletion;
|
||||
};
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,89 +0,0 @@
|
|||
const request = require('supertest');
|
||||
const app = require('../../app');
|
||||
const { Task, Project } = require('../../models');
|
||||
const { createTestUser } = require('../helpers/testUtils');
|
||||
|
||||
describe('Recurring Task Project Change', () => {
|
||||
let user, agent, project;
|
||||
|
||||
beforeEach(async () => {
|
||||
user = await createTestUser({
|
||||
email: 'recurring-project-test@example.com',
|
||||
});
|
||||
|
||||
agent = request.agent(app);
|
||||
await agent.post('/api/login').send({
|
||||
email: 'recurring-project-test@example.com',
|
||||
password: 'password123',
|
||||
});
|
||||
|
||||
// Create a project
|
||||
project = await Project.create({
|
||||
user_id: user.id,
|
||||
name: 'Test Project',
|
||||
});
|
||||
});
|
||||
|
||||
it('should regenerate instances when project_id changes on recurring template', async () => {
|
||||
// Create a daily recurring task without project
|
||||
const taskResponse = await agent.post('/api/task').send({
|
||||
name: 'Daily Task',
|
||||
status: 'not_started',
|
||||
recurrence_type: 'daily',
|
||||
recurrence_interval: 1,
|
||||
due_date: new Date().toISOString(),
|
||||
});
|
||||
|
||||
expect(taskResponse.status).toBe(201);
|
||||
const taskId = taskResponse.body.id;
|
||||
const taskUid = taskResponse.body.uid;
|
||||
|
||||
// Generate instances
|
||||
await agent.post('/api/tasks/generate-recurring');
|
||||
|
||||
// Count instances without project
|
||||
const instancesBeforeCount = await Task.count({
|
||||
where: {
|
||||
user_id: user.id,
|
||||
recurring_parent_id: taskId,
|
||||
},
|
||||
});
|
||||
|
||||
expect(instancesBeforeCount).toBeGreaterThan(0);
|
||||
|
||||
// Verify all instances have no project
|
||||
const instancesBefore = await Task.findAll({
|
||||
where: {
|
||||
user_id: user.id,
|
||||
recurring_parent_id: taskId,
|
||||
},
|
||||
});
|
||||
instancesBefore.forEach((instance) => {
|
||||
expect(instance.project_id).toBeNull();
|
||||
});
|
||||
|
||||
// Update the template to add project_id
|
||||
const updateResponse = await agent.patch(`/api/task/${taskUid}`).send({
|
||||
project_id: project.id,
|
||||
});
|
||||
|
||||
expect(updateResponse.status).toBe(200);
|
||||
|
||||
// Wait for regeneration
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// Count instances after update
|
||||
const instancesAfter = await Task.findAll({
|
||||
where: {
|
||||
user_id: user.id,
|
||||
recurring_parent_id: taskId,
|
||||
due_date: { [require('sequelize').Op.gt]: new Date() },
|
||||
},
|
||||
});
|
||||
|
||||
// All future instances should now have the project_id
|
||||
instancesAfter.forEach((instance) => {
|
||||
expect(instance.project_id).toBe(project.id);
|
||||
});
|
||||
});
|
||||
});
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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();
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -26,10 +26,8 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
const { t } = useTranslation();
|
||||
const store = useStore();
|
||||
|
||||
// Get inbox items count for badge - use pagination.total for accurate count
|
||||
const inboxItemsCount = store.inboxStore.pagination.total;
|
||||
|
||||
// Load inbox items when component mounts to ensure badge shows correct count
|
||||
useEffect(() => {
|
||||
loadInboxItemsToStore(false).catch(console.error);
|
||||
}, []);
|
||||
|
|
@ -60,7 +58,6 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
];
|
||||
|
||||
const isActive = (path: string, query?: string) => {
|
||||
// Handle special case for paths without query parameters
|
||||
if (path === '/inbox' || path === '/today') {
|
||||
const isPathMatch = location.pathname === path;
|
||||
return isPathMatch
|
||||
|
|
@ -68,7 +65,6 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
: 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
|
||||
// Handle upcoming with query parameters
|
||||
if (path.startsWith('/upcoming')) {
|
||||
const isPathMatch = location.pathname === '/upcoming';
|
||||
return isPathMatch
|
||||
|
|
@ -76,7 +72,6 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
: 'text-gray-700 dark:text-gray-300';
|
||||
}
|
||||
|
||||
// Regular case for /tasks with query params
|
||||
const isPathMatch = location.pathname === '/tasks';
|
||||
const isQueryMatch = query
|
||||
? location.search.includes(query)
|
||||
|
|
@ -114,12 +109,24 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
</span>
|
||||
)}
|
||||
{link.path === '/tasks?status=active' && (
|
||||
<button
|
||||
<div
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openTaskModal('full');
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none"
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'Enter' ||
|
||||
e.key === ' '
|
||||
) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
openTaskModal('full');
|
||||
}
|
||||
}}
|
||||
className="text-gray-700 dark:text-gray-300 hover:text-black dark:hover:text-white focus:outline-none cursor-pointer"
|
||||
aria-label={t(
|
||||
'sidebar.addTaskAriaLabel',
|
||||
'Add Task'
|
||||
|
|
@ -130,7 +137,7 @@ const SidebarNav: React.FC<SidebarNavProps> = ({
|
|||
)}
|
||||
>
|
||||
<PlusCircleIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useEffect, useState, useRef } from 'react';
|
||||
import React, { useEffect, useState, useRef, useCallback } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ExclamationTriangleIcon } from '@heroicons/react/24/outline';
|
||||
|
|
@ -46,11 +46,9 @@ const TaskDetails: React.FC = () => {
|
|||
state.tasksStore.tasks.find((t: Task) => t.uid === uid)
|
||||
);
|
||||
|
||||
// Get subtasks from the task data (already loaded in global store)
|
||||
const subtasks = task?.subtasks || task?.Subtasks || [];
|
||||
|
||||
// Local state
|
||||
const [loading, setLoading] = useState(!task); // Only show loading if task not in store
|
||||
const [loading, setLoading] = useState(!task);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [isTaskModalOpen, setIsTaskModalOpen] = useState(false);
|
||||
const [isConfirmDialogOpen, setIsConfirmDialogOpen] = useState(false);
|
||||
|
|
@ -130,23 +128,19 @@ const TaskDetails: React.FC = () => {
|
|||
task?.completion_based,
|
||||
]);
|
||||
|
||||
// Load tags early and check for pending modal state on mount
|
||||
useEffect(() => {
|
||||
// Preload tags if not already loaded
|
||||
if (!tagsStore.hasLoaded && !tagsStore.isLoading) {
|
||||
tagsStore.loadTags();
|
||||
}
|
||||
|
||||
try {
|
||||
// Check for subtasks modal state
|
||||
const pendingStateStr = sessionStorage.getItem('pendingModalState');
|
||||
if (pendingStateStr) {
|
||||
const pendingState = JSON.parse(pendingStateStr);
|
||||
const isRecent = Date.now() - pendingState.timestamp < 2000; // Within 2 seconds
|
||||
const isRecent = Date.now() - pendingState.timestamp < 2000;
|
||||
const isCorrectTask = pendingState.taskId === uid;
|
||||
|
||||
if (isRecent && isCorrectTask && pendingState.isOpen) {
|
||||
// Use microtask to avoid lifecycle method warning
|
||||
queueMicrotask(() => {
|
||||
setIsTaskModalOpen(true);
|
||||
setFocusSubtasks(pendingState.focusSubtasks);
|
||||
|
|
@ -155,17 +149,15 @@ const TaskDetails: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Check for edit modal state
|
||||
const pendingEditStateStr = sessionStorage.getItem(
|
||||
'pendingTaskEditModalState'
|
||||
);
|
||||
if (pendingEditStateStr) {
|
||||
const pendingEditState = JSON.parse(pendingEditStateStr);
|
||||
const isRecent = Date.now() - pendingEditState.timestamp < 5000; // Within 5 seconds
|
||||
const isRecent = Date.now() - pendingEditState.timestamp < 5000;
|
||||
const isCorrectTask = pendingEditState.taskId === uid;
|
||||
|
||||
if (isRecent && isCorrectTask && pendingEditState.isOpen) {
|
||||
// Use microtask to avoid lifecycle method warning
|
||||
queueMicrotask(() => {
|
||||
setIsTaskModalOpen(true);
|
||||
setFocusSubtasks(false);
|
||||
|
|
@ -290,7 +282,6 @@ const TaskDetails: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validate defer_until vs due_date
|
||||
if (task.defer_until && editedDueDate) {
|
||||
const deferDate = new Date(task.defer_until);
|
||||
const dueDate = new Date(editedDueDate);
|
||||
|
|
@ -364,7 +355,6 @@ const TaskDetails: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validate defer_until vs due_date
|
||||
if (editedDeferUntil && task.due_date) {
|
||||
const deferDate = new Date(editedDeferUntil);
|
||||
const dueDate = new Date(task.due_date);
|
||||
|
|
@ -550,12 +540,10 @@ const TaskDetails: React.FC = () => {
|
|||
return;
|
||||
}
|
||||
|
||||
// If task is not in store, load it
|
||||
if (!task) {
|
||||
try {
|
||||
setLoading(true);
|
||||
const fetchedTask = await fetchTaskByUid(uid);
|
||||
// Add the task to the store
|
||||
tasksStore.setTasks([...tasksStore.tasks, fetchedTask]);
|
||||
} catch (fetchError) {
|
||||
setError('Task not found');
|
||||
|
|
@ -564,17 +552,13 @@ const TaskDetails: React.FC = () => {
|
|||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Subtasks are already loaded as part of the task data from the global store
|
||||
};
|
||||
|
||||
fetchTaskData();
|
||||
}, [uid, task, tasksStore]);
|
||||
|
||||
// Load next iterations for recurring tasks (both parent tasks and child tasks)
|
||||
useEffect(() => {
|
||||
const loadNextIterations = async () => {
|
||||
// For parent tasks, use the task's own ID
|
||||
if (
|
||||
task?.id &&
|
||||
task.recurrence_type &&
|
||||
|
|
@ -582,7 +566,13 @@ const TaskDetails: React.FC = () => {
|
|||
) {
|
||||
try {
|
||||
setLoadingIterations(true);
|
||||
const iterations = await fetchTaskNextIterations(task.id);
|
||||
const startFromDate = task.due_date
|
||||
? task.due_date.split('T')[0]
|
||||
: undefined;
|
||||
const iterations = await fetchTaskNextIterations(
|
||||
task.id,
|
||||
startFromDate
|
||||
);
|
||||
setNextIterations(iterations);
|
||||
} catch (error) {
|
||||
console.error('Error loading next iterations:', error);
|
||||
|
|
@ -590,9 +580,7 @@ const TaskDetails: React.FC = () => {
|
|||
} finally {
|
||||
setLoadingIterations(false);
|
||||
}
|
||||
}
|
||||
// For child tasks, use the parent task's ID and start from the child's due date
|
||||
else if (
|
||||
} else if (
|
||||
task?.recurring_parent_id &&
|
||||
parentTask?.id &&
|
||||
parentTask.recurrence_type &&
|
||||
|
|
@ -601,7 +589,6 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
setLoadingIterations(true);
|
||||
|
||||
// If child task has a due date, start iterations from that date
|
||||
const startFromDate = task.due_date
|
||||
? task.due_date.split('T')[0]
|
||||
: undefined;
|
||||
|
|
@ -637,7 +624,6 @@ const TaskDetails: React.FC = () => {
|
|||
parentTask?.last_generated_date,
|
||||
]);
|
||||
|
||||
// Load parent task for child tasks (recurring instances)
|
||||
useEffect(() => {
|
||||
const loadParentTask = async () => {
|
||||
if (task?.recurring_parent_uid) {
|
||||
|
|
@ -672,10 +658,8 @@ const TaskDetails: React.FC = () => {
|
|||
}
|
||||
|
||||
try {
|
||||
// Update task with new subtasks
|
||||
await updateTask(task.uid, { ...task, subtasks: editedSubtasks });
|
||||
|
||||
// Refresh the task from server to get updated subtasks
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
|
|
@ -693,7 +677,6 @@ const TaskDetails: React.FC = () => {
|
|||
);
|
||||
setIsEditingSubtasks(false);
|
||||
|
||||
// Refresh timeline to show subtask changes
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error updating subtasks:', error);
|
||||
|
|
@ -737,7 +720,6 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
await updateTask(task.uid, { ...task, project_id: project.id });
|
||||
|
||||
// Refresh the task from server
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
|
|
@ -754,7 +736,6 @@ const TaskDetails: React.FC = () => {
|
|||
t('task.projectUpdated', 'Project updated successfully')
|
||||
);
|
||||
|
||||
// Refresh timeline
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error updating project:', error);
|
||||
|
|
@ -770,7 +751,6 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
await updateTask(task.uid, { ...task, project_id: null });
|
||||
|
||||
// Refresh the task from server
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
|
|
@ -787,7 +767,6 @@ const TaskDetails: React.FC = () => {
|
|||
t('task.projectCleared', 'Project cleared successfully')
|
||||
);
|
||||
|
||||
// Refresh timeline
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error clearing project:', error);
|
||||
|
|
@ -804,7 +783,6 @@ const TaskDetails: React.FC = () => {
|
|||
e.nativeEvent.stopImmediatePropagation();
|
||||
}
|
||||
|
||||
// Store modal state in sessionStorage to persist across re-mounts
|
||||
const modalState = {
|
||||
isOpen: true,
|
||||
taskId: uid,
|
||||
|
|
@ -819,24 +797,81 @@ const TaskDetails: React.FC = () => {
|
|||
setIsTaskModalOpen(true);
|
||||
};
|
||||
|
||||
const refreshRecurringSetup = useCallback(
|
||||
async (latestTask?: Task | null) => {
|
||||
if (!latestTask) {
|
||||
setNextIterations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
const isTemplateTask =
|
||||
latestTask.recurrence_type &&
|
||||
latestTask.recurrence_type !== 'none' &&
|
||||
!latestTask.recurring_parent_id;
|
||||
const canUseParentIterations =
|
||||
!!latestTask.recurring_parent_id &&
|
||||
!!parentTask?.id &&
|
||||
parentTask?.recurrence_type &&
|
||||
parentTask.recurrence_type !== 'none';
|
||||
|
||||
if (!isTemplateTask && !canUseParentIterations) {
|
||||
setNextIterations([]);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setLoadingIterations(true);
|
||||
if (isTemplateTask) {
|
||||
const startFromDate = latestTask.due_date
|
||||
? latestTask.due_date.split('T')[0]
|
||||
: undefined;
|
||||
const iterations = await fetchTaskNextIterations(
|
||||
latestTask.id,
|
||||
startFromDate
|
||||
);
|
||||
setNextIterations(iterations);
|
||||
} else if (canUseParentIterations && parentTask?.id) {
|
||||
const startFromDate = latestTask.due_date
|
||||
? latestTask.due_date.split('T')[0]
|
||||
: undefined;
|
||||
const iterations = await fetchTaskNextIterations(
|
||||
parentTask.id,
|
||||
startFromDate
|
||||
);
|
||||
setNextIterations(iterations);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error refreshing recurring setup:', error);
|
||||
setNextIterations([]);
|
||||
} finally {
|
||||
setLoadingIterations(false);
|
||||
}
|
||||
},
|
||||
[parentTask?.id, parentTask?.recurrence_type]
|
||||
);
|
||||
|
||||
const handleToggleCompletion = async () => {
|
||||
if (!task?.uid) return;
|
||||
|
||||
try {
|
||||
const updatedTask = await toggleTaskCompletion(task.uid, task);
|
||||
// Update the task in the global store
|
||||
let latestTaskData: Task | null = updatedTask;
|
||||
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const refreshedTask = await fetchTaskByUid(uid);
|
||||
latestTaskData = refreshedTask;
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
(t: Task) => t.uid === uid
|
||||
);
|
||||
if (existingIndex >= 0) {
|
||||
const updatedTasks = [...tasksStore.tasks];
|
||||
updatedTasks[existingIndex] = updatedTask;
|
||||
updatedTasks[existingIndex] = refreshedTask;
|
||||
tasksStore.setTasks(updatedTasks);
|
||||
}
|
||||
}
|
||||
|
||||
await refreshRecurringSetup(latestTaskData);
|
||||
|
||||
const statusMessage =
|
||||
updatedTask.status === 'done' || updatedTask.status === 2
|
||||
? t('task.completedSuccess', 'Task marked as completed')
|
||||
|
|
@ -844,7 +879,6 @@ const TaskDetails: React.FC = () => {
|
|||
|
||||
showSuccessToast(statusMessage);
|
||||
|
||||
// Refresh timeline to show status change activity
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error toggling task completion:', error);
|
||||
|
|
@ -858,7 +892,6 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
if (task?.uid) {
|
||||
await updateTask(task.uid, updatedTask);
|
||||
// Update the task in the global store
|
||||
if (uid) {
|
||||
const updatedTaskFromServer = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
|
|
@ -871,9 +904,6 @@ const TaskDetails: React.FC = () => {
|
|||
}
|
||||
}
|
||||
|
||||
// Subtasks will be automatically updated when the task is reloaded from the global store
|
||||
|
||||
// Refresh timeline to show new activity
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
}
|
||||
setIsTaskModalOpen(false);
|
||||
|
|
@ -897,7 +927,7 @@ const TaskDetails: React.FC = () => {
|
|||
showSuccessToast(
|
||||
t('task.deleteSuccess', 'Task deleted successfully')
|
||||
);
|
||||
navigate('/today'); // Navigate back to today view after deletion
|
||||
navigate('/today');
|
||||
} catch (error) {
|
||||
console.error('Error deleting task:', error);
|
||||
showErrorToast(t('task.deleteError', 'Failed to delete task'));
|
||||
|
|
@ -938,7 +968,6 @@ const TaskDetails: React.FC = () => {
|
|||
return `/tag/${encodeURIComponent(tag.name)}`;
|
||||
};
|
||||
|
||||
// Wrapper handlers for new components
|
||||
const handleTitleUpdate = async (newTitle: string) => {
|
||||
if (!task?.uid || !newTitle.trim()) {
|
||||
return;
|
||||
|
|
@ -951,7 +980,6 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
await updateTask(task.uid, { ...task, name: newTitle.trim() });
|
||||
|
||||
// Update the task in the global store
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
|
|
@ -968,7 +996,6 @@ const TaskDetails: React.FC = () => {
|
|||
t('task.titleUpdated', 'Task title updated successfully')
|
||||
);
|
||||
|
||||
// Refresh timeline to show title change activity
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error updating task title:', error);
|
||||
|
|
@ -993,7 +1020,6 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
await updateTask(task.uid, { ...task, note: trimmedContent });
|
||||
|
||||
// Update the task in the global store
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
|
|
@ -1010,7 +1036,6 @@ const TaskDetails: React.FC = () => {
|
|||
t('task.contentUpdated', 'Task content updated successfully')
|
||||
);
|
||||
|
||||
// Refresh timeline to show content change activity
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error updating task content:', error);
|
||||
|
|
@ -1027,13 +1052,10 @@ const TaskDetails: React.FC = () => {
|
|||
try {
|
||||
const newProject = await createProject({ name });
|
||||
|
||||
// Add to projects store
|
||||
projectsStore.setProjects([...projectsStore.projects, newProject]);
|
||||
|
||||
// Update task with new project
|
||||
await updateTask(task.uid, { ...task, project_id: newProject.id });
|
||||
|
||||
// Refresh the task from server
|
||||
if (uid) {
|
||||
const updatedTask = await fetchTaskByUid(uid);
|
||||
const existingIndex = tasksStore.tasks.findIndex(
|
||||
|
|
@ -1050,7 +1072,6 @@ const TaskDetails: React.FC = () => {
|
|||
t('project.createdAndAssigned', 'Project created and assigned')
|
||||
);
|
||||
|
||||
// Refresh timeline
|
||||
setTimelineRefreshKey((prev) => prev + 1);
|
||||
} catch (error) {
|
||||
console.error('Error creating project:', error);
|
||||
|
|
@ -1302,7 +1323,6 @@ const TaskDetails: React.FC = () => {
|
|||
onClose={() => {
|
||||
setIsTaskModalOpen(false);
|
||||
setFocusSubtasks(false);
|
||||
// Clear pending state when modal is closed
|
||||
sessionStorage.removeItem('pendingModalState');
|
||||
sessionStorage.removeItem(
|
||||
'pendingTaskEditModalState'
|
||||
|
|
|
|||
|
|
@ -219,7 +219,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
<div className="w-full">
|
||||
{/* Full width title that wraps */}
|
||||
<div className="w-full mb-0.5">
|
||||
<span className="text-sm font-normal text-gray-900 dark:text-gray-300 dark:font-light break-words tracking-tight">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-300 break-words tracking-tight">
|
||||
{task.original_name || task.name}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -306,7 +306,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
</div>
|
||||
) : (
|
||||
<div className="flex items-center">
|
||||
<span className="text-md font-normal text-gray-900 dark:text-gray-300 dark:font-light">
|
||||
<span className="text-md font-medium text-gray-900 dark:text-gray-300">
|
||||
{task.original_name || task.name}
|
||||
</span>
|
||||
</div>
|
||||
|
|
@ -581,7 +581,7 @@ const TaskHeader: React.FC<TaskHeaderProps> = ({
|
|||
{/* Task content - full width */}
|
||||
<div className="ml-3 flex-1 min-w-0">
|
||||
{/* Task Title */}
|
||||
<div className="font-light text-md text-gray-900 dark:text-gray-300 dark:font-extralight">
|
||||
<div className="font-medium text-md text-gray-900 dark:text-gray-300">
|
||||
<span className="break-words">
|
||||
{task.original_name || task.name}
|
||||
</span>
|
||||
|
|
|
|||
|
|
@ -99,7 +99,7 @@ const SubtasksDisplay: React.FC<SubtasksDisplayProps> = ({
|
|||
/>
|
||||
</div>
|
||||
<span
|
||||
className={`text-base flex-1 truncate ${
|
||||
className={`text-base font-medium flex-1 truncate ${
|
||||
subtask.status === 'done' ||
|
||||
subtask.status === 2 ||
|
||||
subtask.status === 'archived' ||
|
||||
|
|
|
|||
|
|
@ -28,7 +28,6 @@ import { getApiPath } from '../config/paths';
|
|||
|
||||
const capitalize = (str: string) => str.charAt(0).toUpperCase() + str.slice(1);
|
||||
|
||||
// Helper function to get search placeholder by language
|
||||
const getSearchPlaceholder = (language: string): string => {
|
||||
const placeholders: Record<string, string> = {
|
||||
en: 'Search tasks...',
|
||||
|
|
@ -54,13 +53,12 @@ const Tasks: React.FC = () => {
|
|||
const [dropdownOpen, setDropdownOpen] = useState<boolean>(false);
|
||||
const [orderBy, setOrderBy] = useState<string>('created_at:desc');
|
||||
const [taskSearchQuery, setTaskSearchQuery] = useState<string>('');
|
||||
const [isInfoExpanded, setIsInfoExpanded] = useState(false); // Collapsed by default
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false); // Collapsed by default
|
||||
const [showCompleted, setShowCompleted] = useState(false); // Show completed tasks toggle
|
||||
const [isInfoExpanded, setIsInfoExpanded] = useState(false);
|
||||
const [isSearchExpanded, setIsSearchExpanded] = useState(false);
|
||||
const [showCompleted, setShowCompleted] = useState(false);
|
||||
const [isMobile, setIsMobile] = useState(window.innerWidth < 768);
|
||||
const [groupBy, setGroupBy] = useState<'none' | 'project'>('none');
|
||||
|
||||
// Pagination state
|
||||
const [offset, setOffset] = useState(0);
|
||||
const [hasMore, setHasMore] = useState(false);
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false);
|
||||
|
|
@ -78,27 +76,20 @@ const Tasks: React.FC = () => {
|
|||
const status = query.get('status');
|
||||
const tag = query.get('tag');
|
||||
|
||||
// Sync showCompleted state with status URL parameter (skip for upcoming view)
|
||||
useEffect(() => {
|
||||
if (isUpcomingView) return; // Don't apply status filtering in upcoming view
|
||||
|
||||
if (status === 'completed') {
|
||||
setShowCompleted(true);
|
||||
} else if (status === 'active') {
|
||||
setShowCompleted(false);
|
||||
} else if (status === null) {
|
||||
// When status is null, we show "All" (both completed and active)
|
||||
setShowCompleted(true);
|
||||
}
|
||||
}, [status, isUpcomingView]);
|
||||
|
||||
// Filter tasks based on completion status and search query
|
||||
const displayTasks = useMemo(() => {
|
||||
let filteredTasks: Task[] = tasks;
|
||||
|
||||
// Status-based filtering
|
||||
if (status === 'completed') {
|
||||
// Show only completed tasks
|
||||
filteredTasks = filteredTasks.filter((task: Task) => {
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
|
|
@ -108,7 +99,6 @@ const Tasks: React.FC = () => {
|
|||
return isCompleted;
|
||||
});
|
||||
} else if (status === 'active') {
|
||||
// Show only active (not completed) tasks
|
||||
filteredTasks = filteredTasks.filter((task: Task) => {
|
||||
const isCompleted =
|
||||
task.status === 'done' ||
|
||||
|
|
@ -118,9 +108,7 @@ const Tasks: React.FC = () => {
|
|||
return !isCompleted;
|
||||
});
|
||||
}
|
||||
// When status is null, show all tasks (no filtering)
|
||||
|
||||
// Then filter by search query if provided (skip for upcoming view)
|
||||
if (taskSearchQuery.trim() && !isUpcomingView) {
|
||||
const queryLower = taskSearchQuery.toLowerCase();
|
||||
filteredTasks = filteredTasks.filter(
|
||||
|
|
@ -134,7 +122,6 @@ const Tasks: React.FC = () => {
|
|||
return filteredTasks;
|
||||
}, [tasks, showCompleted, status, taskSearchQuery, isUpcomingView]);
|
||||
|
||||
// Handle the /upcoming route by setting type=upcoming in query params
|
||||
if (location.pathname === '/upcoming' && !query.get('type')) {
|
||||
query.set('type', 'upcoming');
|
||||
}
|
||||
|
|
@ -167,7 +154,6 @@ const Tasks: React.FC = () => {
|
|||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
// Clear search query when switching to upcoming view
|
||||
useEffect(() => {
|
||||
if (isUpcomingView) {
|
||||
setTaskSearchQuery('');
|
||||
|
|
@ -207,22 +193,17 @@ const Tasks: React.FC = () => {
|
|||
const tagId = query.get('tag');
|
||||
const type = query.get('type');
|
||||
|
||||
// Fetch all tasks (both completed and non-completed) for client-side filtering
|
||||
const allTasksUrl = new URLSearchParams(query.toString());
|
||||
// Add special parameter to get ALL tasks (completed and non-completed)
|
||||
allTasksUrl.set('client_side_filtering', 'true');
|
||||
|
||||
// Add groupBy=day for upcoming tasks
|
||||
if (type === 'upcoming') {
|
||||
allTasksUrl.set('type', 'upcoming');
|
||||
allTasksUrl.set('groupBy', 'day');
|
||||
// Always show 7 days (whole week including tomorrow)
|
||||
allTasksUrl.set('maxDays', '7');
|
||||
allTasksUrl.set('sidebarOpen', isSidebarOpen.toString());
|
||||
allTasksUrl.set('isMobile', isMobile.toString());
|
||||
}
|
||||
|
||||
// Add pagination parameters (skip when explicitly disabled or for upcoming view)
|
||||
if (!options?.disablePagination && type !== 'upcoming') {
|
||||
const currentOffset =
|
||||
options?.forceOffset !== undefined
|
||||
|
|
@ -255,7 +236,6 @@ const Tasks: React.FC = () => {
|
|||
}
|
||||
} else {
|
||||
setTasks((prev) => [...prev, ...(tasksData.tasks || [])]);
|
||||
// For grouped tasks, merge them
|
||||
if (tasksData.groupedTasks) {
|
||||
setGroupedTasks((prev) => {
|
||||
if (!prev) return tasksData.groupedTasks;
|
||||
|
|
@ -286,8 +266,6 @@ const Tasks: React.FC = () => {
|
|||
} else {
|
||||
throw new Error('Failed to fetch tasks.');
|
||||
}
|
||||
|
||||
// Projects are now loaded by Layout component into global store
|
||||
} catch (error) {
|
||||
setError((error as Error).message);
|
||||
} finally {
|
||||
|
|
@ -321,7 +299,6 @@ const Tasks: React.FC = () => {
|
|||
};
|
||||
|
||||
useEffect(() => {
|
||||
// Disable pagination for: upcoming view OR when grouping by project
|
||||
const shouldDisablePagination = isUpcomingView || groupBy === 'project';
|
||||
fetchData(
|
||||
true,
|
||||
|
|
@ -336,7 +313,6 @@ const Tasks: React.FC = () => {
|
|||
);
|
||||
}, [location, isSidebarOpen, isMobile, groupBy, isUpcomingView]);
|
||||
|
||||
// Handle window resize for mobile detection
|
||||
useEffect(() => {
|
||||
const handleResize = () => {
|
||||
const newIsMobile = window.innerWidth < 768;
|
||||
|
|
@ -349,7 +325,6 @@ const Tasks: React.FC = () => {
|
|||
return () => window.removeEventListener('resize', handleResize);
|
||||
}, [isMobile]);
|
||||
|
||||
// Listen for task creation from other components (e.g., Layout modal)
|
||||
useEffect(() => {
|
||||
const handleTaskCreated = (event: CustomEvent) => {
|
||||
const newTask = event.detail;
|
||||
|
|
@ -382,10 +357,8 @@ const Tasks: React.FC = () => {
|
|||
const handleTaskCreate = async (taskData: Partial<Task>) => {
|
||||
try {
|
||||
const newTask = await createTask(taskData as Task);
|
||||
// Add the new task optimistically to avoid race conditions
|
||||
setTasks((prevTasks) => [newTask, ...prevTasks]);
|
||||
|
||||
// Show success toast with task link
|
||||
const taskLink = (
|
||||
<span>
|
||||
{t('task.created', 'Task')}{' '}
|
||||
|
|
@ -402,7 +375,7 @@ const Tasks: React.FC = () => {
|
|||
} catch (error) {
|
||||
console.error('Error creating task:', error);
|
||||
setError('Error creating task.');
|
||||
throw error; // Re-throw to allow proper error handling
|
||||
throw error;
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -422,7 +395,6 @@ const Tasks: React.FC = () => {
|
|||
? {
|
||||
...task,
|
||||
...updatedTaskFromServer,
|
||||
// Explicitly preserve subtasks data
|
||||
subtasks:
|
||||
updatedTaskFromServer.subtasks ||
|
||||
updatedTaskFromServer.Subtasks ||
|
||||
|
|
@ -450,7 +422,6 @@ const Tasks: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Handler specifically for task completion toggles (no API call needed, just state update)
|
||||
const handleTaskCompletionToggle = (updatedTask: Task) => {
|
||||
setTasks((prevTasks) =>
|
||||
prevTasks.map((task) =>
|
||||
|
|
@ -458,7 +429,6 @@ const Tasks: React.FC = () => {
|
|||
)
|
||||
);
|
||||
|
||||
// Also update groupedTasks if they exist
|
||||
if (groupedTasks) {
|
||||
setGroupedTasks((prevGroupedTasks) => {
|
||||
if (!prevGroupedTasks) return null;
|
||||
|
|
@ -506,7 +476,6 @@ const Tasks: React.FC = () => {
|
|||
): Promise<void> => {
|
||||
try {
|
||||
await toggleTaskToday(taskId, task);
|
||||
// Refetch data to ensure consistency with all task relationships
|
||||
const params = new URLSearchParams(location.search);
|
||||
const type = params.get('type') || 'all';
|
||||
const tag = params.get('tag');
|
||||
|
|
@ -547,7 +516,6 @@ const Tasks: React.FC = () => {
|
|||
setDropdownOpen(false);
|
||||
};
|
||||
|
||||
// Sort options for tasks
|
||||
const sortOptions: SortOption[] = [
|
||||
{ value: 'due_date:asc', label: t('sort.due_date', 'Due Date') },
|
||||
{ value: 'name:asc', label: t('sort.name', 'Name') },
|
||||
|
|
@ -658,8 +626,8 @@ const Tasks: React.FC = () => {
|
|||
dropdownLabel={t('tasks.sortBy', 'Sort by')}
|
||||
align="right"
|
||||
footerContent={
|
||||
!isUpcomingView && (
|
||||
<div className="space-y-3">
|
||||
<div className="space-y-3">
|
||||
{!isUpcomingView && (
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.groupBy', 'Group by')}
|
||||
|
|
@ -707,193 +675,183 @@ const Tasks: React.FC = () => {
|
|||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.show', 'Show')}
|
||||
</div>
|
||||
<div className="py-1 space-y-1">
|
||||
{[
|
||||
{
|
||||
key: 'active',
|
||||
label: t(
|
||||
'tasks.open',
|
||||
'Open'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: t(
|
||||
'tasks.all',
|
||||
'All'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: t(
|
||||
'tasks.completed',
|
||||
'Completed'
|
||||
),
|
||||
},
|
||||
].map((opt) => {
|
||||
const isActive =
|
||||
(opt.key === 'all' &&
|
||||
status === null) ||
|
||||
(opt.key ===
|
||||
'completed' &&
|
||||
status ===
|
||||
'completed') ||
|
||||
(opt.key === 'active' &&
|
||||
status ===
|
||||
'active');
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
opt.key ===
|
||||
'completed'
|
||||
) {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.set(
|
||||
'status',
|
||||
'completed'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
opt.key ===
|
||||
'all'
|
||||
) {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.delete(
|
||||
'status'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
// active (not completed)
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.set(
|
||||
'status',
|
||||
'active'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{opt.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.show', 'Show')}
|
||||
</div>
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t(
|
||||
'tasks.direction',
|
||||
'Direction'
|
||||
)}
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{[
|
||||
{
|
||||
key: 'asc',
|
||||
label: t(
|
||||
'tasks.ascending',
|
||||
'Ascending'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: t(
|
||||
'tasks.descending',
|
||||
'Descending'
|
||||
),
|
||||
},
|
||||
].map((dir) => {
|
||||
const currentDirection =
|
||||
orderBy.split(':')[1] ||
|
||||
'asc';
|
||||
const isActive =
|
||||
currentDirection ===
|
||||
dir.key;
|
||||
return (
|
||||
<button
|
||||
key={dir.key}
|
||||
onClick={() => {
|
||||
const [field] =
|
||||
orderBy.split(
|
||||
':'
|
||||
<div className="py-1 space-y-1">
|
||||
{[
|
||||
{
|
||||
key: 'active',
|
||||
label: t(
|
||||
'tasks.open',
|
||||
'Open'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'all',
|
||||
label: t(
|
||||
'tasks.all',
|
||||
'All'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'completed',
|
||||
label: t(
|
||||
'tasks.completed',
|
||||
'Completed'
|
||||
),
|
||||
},
|
||||
].map((opt) => {
|
||||
const isActive =
|
||||
(opt.key === 'all' &&
|
||||
status === null) ||
|
||||
(opt.key === 'completed' &&
|
||||
status ===
|
||||
'completed') ||
|
||||
(opt.key === 'active' &&
|
||||
status === 'active');
|
||||
return (
|
||||
<button
|
||||
key={opt.key}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (
|
||||
opt.key ===
|
||||
'completed'
|
||||
) {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
const newOrderBy = `${field}:${dir.key}`;
|
||||
handleSortChange(
|
||||
newOrderBy
|
||||
params.set(
|
||||
'status',
|
||||
'completed'
|
||||
);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>
|
||||
{dir.label}
|
||||
</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
} else if (
|
||||
opt.key ===
|
||||
'all'
|
||||
) {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.delete(
|
||||
'status'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
} else {
|
||||
const params =
|
||||
new URLSearchParams(
|
||||
location.search
|
||||
);
|
||||
params.set(
|
||||
'status',
|
||||
'active'
|
||||
);
|
||||
navigate(
|
||||
{
|
||||
pathname:
|
||||
location.pathname,
|
||||
search: `?${params.toString()}`,
|
||||
},
|
||||
{
|
||||
replace: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>{opt.label}</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
<div>
|
||||
<div className="px-3 py-2 text-xs font-bold text-gray-500 dark:text-gray-400 bg-gray-50 dark:bg-gray-900/50 border-t border-b border-gray-200 dark:border-gray-700">
|
||||
{t('tasks.direction', 'Direction')}
|
||||
</div>
|
||||
<div className="py-1">
|
||||
{[
|
||||
{
|
||||
key: 'asc',
|
||||
label: t(
|
||||
'tasks.ascending',
|
||||
'Ascending'
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'desc',
|
||||
label: t(
|
||||
'tasks.descending',
|
||||
'Descending'
|
||||
),
|
||||
},
|
||||
].map((dir) => {
|
||||
const currentDirection =
|
||||
orderBy.split(':')[1] ||
|
||||
'asc';
|
||||
const isActive =
|
||||
currentDirection ===
|
||||
dir.key;
|
||||
return (
|
||||
<button
|
||||
key={dir.key}
|
||||
onClick={() => {
|
||||
const [field] =
|
||||
orderBy.split(
|
||||
':'
|
||||
);
|
||||
const newOrderBy = `${field}:${dir.key}`;
|
||||
handleSortChange(
|
||||
newOrderBy
|
||||
);
|
||||
}}
|
||||
className={`w-full text-left px-3 py-2 text-sm transition-colors flex items-center justify-between ${
|
||||
isActive
|
||||
? 'bg-blue-50 dark:bg-blue-900/20 text-blue-600 dark:text-blue-400'
|
||||
: 'text-gray-700 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
<span>{dir.label}</span>
|
||||
{isActive && (
|
||||
<CheckIcon className="h-4 w-4" />
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
|
@ -982,7 +940,7 @@ const Tasks: React.FC = () => {
|
|||
onTaskDelete={handleTaskDelete}
|
||||
projects={projects}
|
||||
hideProjectName={false}
|
||||
onToggleToday={undefined} // Don't show "Add to Today" in upcoming view
|
||||
onToggleToday={undefined}
|
||||
showCompletedTasks={showCompleted}
|
||||
searchQuery={taskSearchQuery}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue