feat(recurring-tasks): enhance recurring task functionality and documentation

- Expanded schedule options for recurring tasks, including new intervals for every X days, weeks, and months.
- Added future task creation logic to ensure tasks are created within defined limits based on their schedule type.
- Updated user guide to reflect new scheduling options and future task creation details.
- Improved backend logic for recurring task creation, including batch processing and future limit calculations.
- Added environment configuration for enabling recurring jobs.
- Enhanced frontend localization for recurring task configuration labels.
This commit is contained in:
chamiakJ
2025-05-21 08:07:59 +05:30
parent a568ee808f
commit 84c7428fed
10 changed files with 255 additions and 129 deletions

View File

@@ -16,24 +16,45 @@ Recurring tasks are tasks that repeat automatically on a schedule you choose. Th
5. Save the task. It will now be created automatically based on your chosen schedule.
## Schedule Options
You can choose how often your task repeats. Here are the most common options:
You can choose how often your task repeats. Here are the available options:
- **Daily:** The task is created every day.
- **Weekly:** The task is created once a week. You can pick the day (e.g., every Monday).
- **Monthly:** The task is created once a month. You can pick the date (e.g., the 1st of every month).
- **Weekdays:** The task is created every Monday to Friday.
- **Custom:** Set your own schedule, such as every 2 days, every 3 weeks, or only on certain days.
- **Weekly:** The task is created once a week. You can pick one or more days (e.g., every Monday and Thursday).
- **Monthly:** The task is created once a month. You have two options:
- **On a specific date:** Choose a date from 1 to 28 (limited to 28 to ensure consistency across all months)
- **On a specific day:** Choose a week (first, second, third, fourth, or last) and a day of the week
- **Every X Days:** The task is created every specified number of days (e.g., every 3 days)
- **Every X Weeks:** The task is created every specified number of weeks (e.g., every 2 weeks)
- **Every X Months:** The task is created every specified number of months (e.g., every 3 months)
### Examples
- "Send team update" every Friday (weekly)
- "Submit expense report" on the 1st of each month (monthly)
- "Submit expense report" on the 15th of each month (monthly, specific date)
- "Monthly team meeting" on the first Monday of each month (monthly, specific day)
- "Check backups" every day (daily)
- "Review project status" every Monday and Thursday (custom)
- "Review project status" every Monday and Thursday (weekly, multiple days)
- "Quarterly report" every 3 months (every X months)
## Future Task Creation
The system automatically creates tasks up to a certain point in the future to ensure timely scheduling:
- **Daily Tasks:** Created up to 7 days in advance
- **Weekly Tasks:** Created up to 2 weeks in advance
- **Monthly Tasks:** Created up to 2 months in advance
- **Every X Days/Weeks/Months:** Created up to 2 intervals in advance
This ensures that:
- You always have upcoming tasks visible in your schedule
- Tasks are created at appropriate intervals
- The system maintains a reasonable number of future tasks
## Tips
- You can edit or stop a recurring task at any time.
- Assign team members and labels to recurring tasks for better organization.
- Check your task list regularly to see newly created recurring tasks.
- For monthly tasks, dates are limited to 1-28 to ensure the task occurs on the same date every month.
- Tasks are created automatically within the future limit window - you don't need to manually create them.
- If you need to see tasks further in the future, they will be created automatically as the current tasks are completed.
## Need Help?
If you have questions or need help setting up recurring tasks, contact your workspace admin or support team.

View File

@@ -17,6 +17,51 @@ The recurring tasks cron job automates the creation of tasks based on predefined
3. Checks if a task for the next occurrence already exists.
4. Creates a new task if it does not exist and the next occurrence is within the allowed future window.
## Future Limit Logic
The system implements different future limits based on the schedule type to maintain an appropriate number of future tasks:
```typescript
const FUTURE_LIMITS = {
daily: moment.duration(7, 'days'),
weekly: moment.duration(2, 'weeks'),
monthly: moment.duration(2, 'months'),
every_x_days: (interval: number) => moment.duration(interval * 2, 'days'),
every_x_weeks: (interval: number) => moment.duration(interval * 2, 'weeks'),
every_x_months: (interval: number) => moment.duration(interval * 2, 'months')
};
```
### Implementation Details
- **Base Calculation:**
```typescript
const futureLimit = moment(template.last_checked_at || template.created_at)
.add(getFutureLimit(schedule.schedule_type, schedule.interval), 'days');
```
- **Task Creation Rules:**
1. Only create tasks if the next occurrence is before the future limit
2. Skip creation if a task already exists for that date
3. Update `last_checked_at` after processing
- **Benefits:**
- Prevents excessive task creation
- Maintains system performance
- Ensures timely task visibility
- Allows for schedule modifications
## Date Handling
- **Monthly Tasks:**
- Dates are limited to 1-28 to ensure consistency across all months
- This prevents issues with months having different numbers of days
- No special handling needed for February or months with 30/31 days
- **Weekly Tasks:**
- Supports multiple days of the week (0-6, where 0 is Sunday)
- Tasks are created for each selected day
- **Interval-based Tasks:**
- Every X days/weeks/months from the last task's end date
- Minimum interval is 1 day/week/month
- No maximum limit, but tasks are only created up to the future limit
## Database Interactions
- **Templates and Schedules:**
- Templates are stored in `task_recurring_templates`.
@@ -27,6 +72,7 @@ The recurring tasks cron job automates the creation of tasks based on predefined
- Assigns team members and labels by calling appropriate functions/controllers.
- **State Tracking:**
- Updates `last_checked_at` and `last_created_task_end_date` in the schedule after processing.
- Maintains future limits based on schedule type.
## Task Creation Process
1. **Fetch Templates:** Retrieve all templates and their associated schedules.
@@ -41,10 +87,12 @@ The recurring tasks cron job automates the creation of tasks based on predefined
- **Cron Expression:** Modify the `TIME` constant in the code to change the schedule.
- **Task Template Structure:** Extend the template or schedule interfaces to support additional fields.
- **Task Creation Logic:** Customize the task creation process or add new assignment/labeling logic as needed.
- **Future Window:** Adjust the future limits by modifying the `FUTURE_LIMITS` configuration.
## Error Handling
- Errors are logged using the `log_error` utility.
- The job continues processing other templates even if one fails.
- Failed task creations are not retried automatically.
## References
- Source: `src/cron_jobs/recurring-tasks.ts`

View File

@@ -79,3 +79,7 @@ GOOGLE_CAPTCHA_PASS_SCORE=0.8
# Email Cronjobs
ENABLE_EMAIL_CRONJOBS=true
# RECURRING_JOBS
ENABLE_RECURRING_JOBS=true
RECURRING_JOBS_INTERVAL="0 11 */1 * 1-5"

View File

@@ -32,23 +32,14 @@ export default class TasksControllerBase extends WorklenzControllerBase {
}
public static updateTaskViewModel(task: any) {
console.log(`Processing task ${task.id} (${task.name})`);
console.log(` manual_progress: ${task.manual_progress}, progress_value: ${task.progress_value}`);
console.log(` project_use_manual_progress: ${task.project_use_manual_progress}, project_use_weighted_progress: ${task.project_use_weighted_progress}`);
console.log(` has subtasks: ${task.sub_tasks_count > 0}`);
// For parent tasks (with subtasks), always use calculated progress from subtasks
if (task.sub_tasks_count > 0) {
// For parent tasks without manual progress, calculate from subtasks (already done via db function)
console.log(` Parent task with subtasks: complete_ratio=${task.complete_ratio}`);
// Ensure progress matches complete_ratio for consistency
task.progress = task.complete_ratio || 0;
// Important: Parent tasks should not have manual progress
// If they somehow do, reset it
if (task.manual_progress) {
console.log(` WARNING: Parent task ${task.id} had manual_progress set to true, resetting`);
task.manual_progress = false;
task.progress_value = null;
}
@@ -58,8 +49,6 @@ export default class TasksControllerBase extends WorklenzControllerBase {
// For manually set progress, use that value directly
task.progress = parseInt(task.progress_value);
task.complete_ratio = parseInt(task.progress_value);
console.log(` Using manual progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
}
// For tasks with no subtasks and no manual progress, calculate based on time
else {
@@ -69,8 +58,6 @@ export default class TasksControllerBase extends WorklenzControllerBase {
// Set complete_ratio to match progress
task.complete_ratio = task.progress;
console.log(` Calculated time-based progress: progress=${task.progress}, complete_ratio=${task.complete_ratio}`);
}
// Ensure numeric values

View File

@@ -97,7 +97,6 @@ export default class TasksControllerV2 extends TasksControllerBase {
try {
const result = await db.query("SELECT get_task_complete_ratio($1) AS info;", [taskId]);
const [data] = result.rows;
console.log("data", data);
if (data && data.info && data.info.ratio !== undefined) {
data.info.ratio = +((data.info.ratio || 0).toFixed());
return data.info;

View File

@@ -7,43 +7,48 @@ import TasksController from "../controllers/tasks-controller";
// At 11:00+00 (4.30pm+530) on every day-of-month if it's on every day-of-week from Monday through Friday.
// const TIME = "0 11 */1 * 1-5";
const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes
const TIME = process.env.RECURRING_JOBS_INTERVAL || "0 11 */1 * 1-5";
const TIME_FORMAT = "YYYY-MM-DD";
// const TIME = "0 0 * * *"; // Runs at midnight every day
const log = (value: any) => console.log("recurring-task-cron-job:", value);
async function onRecurringTaskJobTick() {
try {
log("(cron) Recurring tasks job started.");
// Define future limits for different schedule types
// More conservative limits to prevent task list clutter
const FUTURE_LIMITS = {
daily: moment.duration(3, "days"),
weekly: moment.duration(1, "week"),
monthly: moment.duration(1, "month"),
every_x_days: (interval: number) => moment.duration(interval, "days"),
every_x_weeks: (interval: number) => moment.duration(interval, "weeks"),
every_x_months: (interval: number) => moment.duration(interval, "months")
};
const templatesQuery = `
SELECT t.*, s.*, (SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date
FROM task_recurring_templates t
JOIN task_recurring_schedules s ON t.schedule_id = s.id;
`;
const templatesResult = await db.query(templatesQuery);
const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule)[];
const now = moment();
let createdTaskCount = 0;
for (const template of templates) {
const lastTaskEndDate = template.last_task_end_date
? moment(template.last_task_end_date)
: moment(template.created_at);
const futureLimit = moment(template.last_checked_at || template.created_at).add(1, "week");
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
// Find the next future occurrence
while (nextEndDate.isSameOrBefore(now)) {
nextEndDate = calculateNextEndDate(template, nextEndDate);
// Helper function to get the future limit based on schedule type
function getFutureLimit(scheduleType: string, interval?: number): moment.Duration {
switch (scheduleType) {
case "daily":
return FUTURE_LIMITS.daily;
case "weekly":
return FUTURE_LIMITS.weekly;
case "monthly":
return FUTURE_LIMITS.monthly;
case "every_x_days":
return FUTURE_LIMITS.every_x_days(interval || 1);
case "every_x_weeks":
return FUTURE_LIMITS.every_x_weeks(interval || 1);
case "every_x_months":
return FUTURE_LIMITS.every_x_months(interval || 1);
default:
return moment.duration(3, "days"); // Default to 3 days
}
}
// Only create a task if it's within the future limit
if (nextEndDate.isSameOrBefore(futureLimit)) {
// Helper function to batch create tasks
async function createBatchTasks(template: ITaskTemplate & IRecurringSchedule, endDates: moment.Moment[]) {
const createdTasks = [];
for (const nextEndDate of endDates) {
const existingTaskQuery = `
SELECT id FROM tasks
WHERE schedule_id = $1 AND end_date::DATE = $2::DATE;
@@ -65,7 +70,7 @@ async function onRecurringTaskJobTick() {
const createdTask = createTaskResult.rows[0].task;
if (createdTask) {
createdTaskCount++;
createdTasks.push(createdTask);
for (const assignee of template.assignees) {
await TasksController.createTaskBulkAssignees(assignee.team_member_id, template.project_id, createdTask.id, assignee.assigned_by);
@@ -81,17 +86,69 @@ async function onRecurringTaskJobTick() {
} else {
console.log(`Skipped creating task for template ${template.name} with end date ${nextEndDate.format(TIME_FORMAT)} - task already exists`);
}
} else {
console.log(`No task created for template ${template.name} - next occurrence is beyond the future limit`);
}
return createdTasks;
}
async function onRecurringTaskJobTick() {
try {
log("(cron) Recurring tasks job started.");
const templatesQuery = `
SELECT t.*, s.*, (SELECT MAX(end_date) FROM tasks WHERE schedule_id = s.id) as last_task_end_date
FROM task_recurring_templates t
JOIN task_recurring_schedules s ON t.schedule_id = s.id;
`;
const templatesResult = await db.query(templatesQuery);
const templates = templatesResult.rows as (ITaskTemplate & IRecurringSchedule)[];
const now = moment();
let createdTaskCount = 0;
for (const template of templates) {
const lastTaskEndDate = template.last_task_end_date
? moment(template.last_task_end_date)
: moment(template.created_at);
// Calculate future limit based on schedule type
const futureLimit = moment(template.last_checked_at || template.created_at)
.add(getFutureLimit(
template.schedule_type,
template.interval_days || template.interval_weeks || template.interval_months || 1
));
let nextEndDate = calculateNextEndDate(template, lastTaskEndDate);
const endDatesToCreate: moment.Moment[] = [];
// Find all future occurrences within the limit
while (nextEndDate.isSameOrBefore(futureLimit)) {
if (nextEndDate.isAfter(now)) {
endDatesToCreate.push(moment(nextEndDate));
}
nextEndDate = calculateNextEndDate(template, nextEndDate);
}
// Batch create tasks for all future dates
if (endDatesToCreate.length > 0) {
const createdTasks = await createBatchTasks(template, endDatesToCreate);
createdTaskCount += createdTasks.length;
// Update the last_checked_at in the schedule
const updateScheduleQuery = `
UPDATE task_recurring_schedules
SET last_checked_at = $1::DATE, last_created_task_end_date = $2
SET last_checked_at = $1::DATE,
last_created_task_end_date = $2
WHERE id = $3;
`;
await db.query(updateScheduleQuery, [moment(template.last_checked_at || template.created_at).add(1, "day").format(TIME_FORMAT), nextEndDate.format(TIME_FORMAT), template.schedule_id]);
await db.query(updateScheduleQuery, [
moment().format(TIME_FORMAT),
endDatesToCreate[endDatesToCreate.length - 1].format(TIME_FORMAT),
template.schedule_id
]);
} else {
console.log(`No tasks created for template ${template.name} - next occurrence is beyond the future limit`);
}
}
log(`(cron) Recurring tasks job ended with ${createdTaskCount} new tasks created.`);

View File

@@ -2,6 +2,7 @@
"recurring": "Recurring",
"recurringTaskConfiguration": "Recurring task configuration",
"repeats": "Repeats",
"daily": "Daily",
"weekly": "Weekly",
"everyXDays": "Every X Days",
"everyXWeeks": "Every X Weeks",

View File

@@ -2,6 +2,7 @@
"recurring": "Recurrente",
"recurringTaskConfiguration": "Configuración de tarea recurrente",
"repeats": "Repeticiones",
"daily": "Diario",
"weekly": "Semanal",
"everyXDays": "Cada X días",
"everyXWeeks": "Cada X semanas",

View File

@@ -2,6 +2,7 @@
"recurring": "Recorrente",
"recurringTaskConfiguration": "Configuração de tarefa recorrente",
"repeats": "Repete",
"daily": "Diário",
"weekly": "Semanal",
"everyXDays": "A cada X dias",
"everyXWeeks": "A cada X semanas",

View File

@@ -24,44 +24,46 @@ import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'
import logger from '@/utils/errorLogger';
import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice';
const repeatOptions: IRepeatOption[] = [
{ label: 'Daily', value: ITaskRecurring.Daily },
{ label: 'Weekly', value: ITaskRecurring.Weekly },
{ label: 'Every X Days', value: ITaskRecurring.EveryXDays },
{ label: 'Every X Weeks', value: ITaskRecurring.EveryXWeeks },
{ label: 'Every X Months', value: ITaskRecurring.EveryXMonths },
{ label: 'Monthly', value: ITaskRecurring.Monthly },
];
const daysOfWeek = [
{ label: 'Sunday', value: 0, checked: false },
{ label: 'Monday', value: 1, checked: false },
{ label: 'Tuesday', value: 2, checked: false },
{ label: 'Wednesday', value: 3, checked: false },
{ label: 'Thursday', value: 4, checked: false },
{ label: 'Friday', value: 5, checked: false },
{ label: 'Saturday', value: 6, checked: false }
];
const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1);
const weekOptions = [
{ label: 'First', value: 1 },
{ label: 'Second', value: 2 },
{ label: 'Third', value: 3 },
{ label: 'Fourth', value: 4 },
{ label: 'Last', value: 5 }
];
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
const { socket, connected } = useSocket();
const dispatch = useAppDispatch();
const { t } = useTranslation('task-drawer/task-drawer-recurring-config');
const repeatOptions: IRepeatOption[] = [
{ label: t('daily'), value: ITaskRecurring.Daily },
{ label: t('weekly'), value: ITaskRecurring.Weekly },
{ label: t('everyXDays'), value: ITaskRecurring.EveryXDays },
{ label: t('everyXWeeks'), value: ITaskRecurring.EveryXWeeks },
{ label: t('everyXMonths'), value: ITaskRecurring.EveryXMonths },
{ label: t('monthly'), value: ITaskRecurring.Monthly },
];
const daysOfWeek = [
{ label: t('sun'), value: 0, checked: false },
{ label: t('mon'), value: 1, checked: false },
{ label: t('tue'), value: 2, checked: false },
{ label: t('wed'), value: 3, checked: false },
{ label: t('thu'), value: 4, checked: false },
{ label: t('fri'), value: 5, checked: false },
{ label: t('sat'), value: 6, checked: false }
];
const weekOptions = [
{ label: t('first'), value: 1 },
{ label: t('second'), value: 2 },
{ label: t('third'), value: 3 },
{ label: t('fourth'), value: 4 },
{ label: t('last'), value: 5 }
];
const dayOptions = daysOfWeek.map(d => ({ label: d.label, value: d.value }));
const [recurring, setRecurring] = useState(false);
const [showConfig, setShowConfig] = useState(false);
const [repeatOption, setRepeatOption] = useState<IRepeatOption>({});
const [selectedDays, setSelectedDays] = useState([]);
const [selectedDays, setSelectedDays] = useState<number[]>([]);
const [monthlyOption, setMonthlyOption] = useState('date');
const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1);
const [selectedMonthlyWeek, setSelectedMonthlyWeek] = useState(weekOptions[0].value);
@@ -106,8 +108,8 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
[repeatOption]
);
const handleDayCheckboxChange = (checkedValues: string[]) => {
setSelectedDays(checkedValues as unknown as string[]);
const handleDayCheckboxChange = (checkedValues: number[]) => {
setSelectedDays(checkedValues);
};
const getSelectedDays = () => {
@@ -165,7 +167,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body);
if (res.done) {
setRecurring(true);
setShowConfig(false);
configVisibleChange(false);
}
} catch (e) {
logger.error("handleSave", e);
@@ -220,9 +224,9 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
if (!task) return;
if (task) setRecurring(!!task.schedule_id);
if (recurring) void getScheduleData();
if (task.schedule_id) void getScheduleData();
socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse);
}, [task]);
}, [task?.schedule_id]);
return (
<div>
@@ -232,11 +236,11 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
&nbsp;
{recurring && (
<Popover
title="Recurring task configuration"
title={t('recurringTaskConfiguration')}
content={
<Skeleton loading={loadingData} active>
<Form layout="vertical">
<Form.Item label="Repeats">
<Form.Item label={t('repeats')}>
<Select
value={repeatOption.value}
onChange={val => {
@@ -251,9 +255,12 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
</Form.Item>
{repeatOption.value === ITaskRecurring.Weekly && (
<Form.Item label="Select Days of the Week">
<Form.Item label={t('selectDaysOfWeek')}>
<Checkbox.Group
options={daysOfWeek}
options={daysOfWeek.map(day => ({
label: day.label,
value: day.value
}))}
value={selectedDays}
onChange={handleDayCheckboxChange}
style={{ width: '100%' }}
@@ -271,17 +278,17 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
{isMonthlySelected && (
<>
<Form.Item label="Monthly repeat type">
<Form.Item label={t('monthlyRepeatType')}>
<Radio.Group
value={monthlyOption}
onChange={e => setMonthlyOption(e.target.value)}
>
<Radio.Button value="date">On a specific date</Radio.Button>
<Radio.Button value="day">On a specific day</Radio.Button>
<Radio.Button value="date">{t('onSpecificDate')}</Radio.Button>
<Radio.Button value="day">{t('onSpecificDay')}</Radio.Button>
</Radio.Group>
</Form.Item>
{monthlyOption === 'date' && (
<Form.Item label="Date of the month">
<Form.Item label={t('dateOfMonth')}>
<Select
value={selectedMonthlyDate}
onChange={setSelectedMonthlyDate}
@@ -295,7 +302,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
)}
{monthlyOption === 'day' && (
<>
<Form.Item label="Week of the month">
<Form.Item label={t('weekOfMonth')}>
<Select
value={selectedMonthlyWeek}
onChange={setSelectedMonthlyWeek}
@@ -303,7 +310,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
style={{ width: 150 }}
/>
</Form.Item>
<Form.Item label="Day of the week">
<Form.Item label={t('dayOfWeek')}>
<Select
value={selectedMonthlyDay}
onChange={setSelectedMonthlyDay}
@@ -317,7 +324,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
)}
{repeatOption.value === ITaskRecurring.EveryXDays && (
<Form.Item label="Interval (days)">
<Form.Item label={t('intervalDays')}>
<InputNumber
min={1}
value={intervalDays}
@@ -326,7 +333,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
</Form.Item>
)}
{repeatOption.value === ITaskRecurring.EveryXWeeks && (
<Form.Item label="Interval (weeks)">
<Form.Item label={t('intervalWeeks')}>
<InputNumber
min={1}
value={intervalWeeks}
@@ -335,7 +342,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
</Form.Item>
)}
{repeatOption.value === ITaskRecurring.EveryXMonths && (
<Form.Item label="Interval (months)">
<Form.Item label={t('intervalMonths')}>
<InputNumber
min={1}
value={intervalMonths}
@@ -350,7 +357,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => {
loading={updatingData}
onClick={handleSave}
>
Save Changes
{t('saveChanges')}
</Button>
</Form.Item>
</Form>