From 2e985bd05161bd337a7904820f3ad17706c3edd8 Mon Sep 17 00:00:00 2001 From: chamikaJ Date: Fri, 16 May 2025 14:32:45 +0530 Subject: [PATCH] feat(recurring-tasks): implement recurring task scheduling and API integration --- worklenz-backend/src/cron_jobs/index.ts | 4 +- .../src/cron_jobs/recurring-tasks.ts | 2 +- .../api/tasks/task-recurring.api.service.ts | 16 ++ .../task-drawer-recurring-config.tsx | 164 +++++++++++++----- .../features/task-drawer/task-drawer.slice.ts | 10 ++ .../types/tasks/task-recurring-schedule.ts | 48 +++-- 6 files changed, 185 insertions(+), 59 deletions(-) create mode 100644 worklenz-frontend/src/api/tasks/task-recurring.api.service.ts diff --git a/worklenz-backend/src/cron_jobs/index.ts b/worklenz-backend/src/cron_jobs/index.ts index f13ec2e8..20bd4f62 100644 --- a/worklenz-backend/src/cron_jobs/index.ts +++ b/worklenz-backend/src/cron_jobs/index.ts @@ -1,11 +1,11 @@ import {startDailyDigestJob} from "./daily-digest-job"; import {startNotificationsJob} from "./notifications-job"; import {startProjectDigestJob} from "./project-digest-job"; -import { startRecurringTasksJob } from "./recurring-tasks"; +import {startRecurringTasksJob} from "./recurring-tasks"; export function startCronJobs() { startNotificationsJob(); startDailyDigestJob(); startProjectDigestJob(); - // startRecurringTasksJob(); + startRecurringTasksJob(); } diff --git a/worklenz-backend/src/cron_jobs/recurring-tasks.ts b/worklenz-backend/src/cron_jobs/recurring-tasks.ts index a9ae7847..16854c7e 100644 --- a/worklenz-backend/src/cron_jobs/recurring-tasks.ts +++ b/worklenz-backend/src/cron_jobs/recurring-tasks.ts @@ -7,7 +7,7 @@ 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 * * * *"; +const TIME = "*/2 * * * *"; // runs every 2 minutes - for testing purposes const TIME_FORMAT = "YYYY-MM-DD"; // const TIME = "0 0 * * *"; // Runs at midnight every day diff --git a/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts new file mode 100644 index 00000000..6e19d7cb --- /dev/null +++ b/worklenz-frontend/src/api/tasks/task-recurring.api.service.ts @@ -0,0 +1,16 @@ +import { API_BASE_URL } from "@/shared/constants"; +import { IServerResponse } from "@/types/common.types"; +import { ITaskRecurringSchedule } from "@/types/tasks/task-recurring-schedule"; +import apiClient from "../api-client"; + +const rootUrl = `${API_BASE_URL}/task-recurring`; + +export const taskRecurringApiService = { + getTaskRecurringData: async (schedule_id: string): Promise> => { + const response = await apiClient.get(`${rootUrl}/${schedule_id}`); + return response.data; + }, + updateTaskRecurringData: async (schedule_id: string, body: any): Promise> => { + return apiClient.put(`${rootUrl}/${schedule_id}`, body); + } +} \ No newline at end of file diff --git a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx index 1e5af1d8..608fc321 100644 --- a/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx +++ b/worklenz-frontend/src/components/task-drawer/shared/info-tab/details/task-drawer-recurring-config/task-drawer-recurring-config.tsx @@ -15,21 +15,17 @@ import { import { SettingOutlined } from '@ant-design/icons'; import { useSocket } from '@/socket/socketContext'; import { SocketEvents } from '@/shared/socket-events'; -import { ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; +import { IRepeatOption, ITaskRecurring, ITaskRecurringSchedule, ITaskRecurringScheduleData } from '@/types/tasks/task-recurring-schedule'; import { ITaskViewModel } from '@/types/tasks/task.types'; import { useTranslation } from 'react-i18next'; import { useAppDispatch } from '@/hooks/useAppDispatch'; import { updateRecurringChange } from '@/features/tasks/tasks.slice'; +import { taskRecurringApiService } from '@/api/tasks/task-recurring.api.service'; +import logger from '@/utils/errorLogger'; +import { setTaskRecurringSchedule } from '@/features/task-drawer/task-drawer.slice'; -const ITaskRecurring = { - Weekly: 'weekly', - EveryXDays: 'every_x_days', - EveryXWeeks: 'every_x_weeks', - EveryXMonths: 'every_x_months', - Monthly: 'monthly', -}; - -const repeatOptions = [ +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 }, @@ -38,22 +34,22 @@ const repeatOptions = [ ]; const daysOfWeek = [ - { label: 'Mon', value: 'mon' }, - { label: 'Tue', value: 'tue' }, - { label: 'Wed', value: 'wed' }, - { label: 'Thu', value: 'thu' }, - { label: 'Fri', value: 'fri' }, - { label: 'Sat', value: 'sat' }, - { label: 'Sun', value: 'sun' }, + { 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: 31 }, (_, i) => i + 1); +const monthlyDateOptions = Array.from({ length: 28 }, (_, i) => i + 1); const weekOptions = [ - { label: 'First', value: 'first' }, - { label: 'Second', value: 'second' }, - { label: 'Third', value: 'third' }, - { label: 'Fourth', value: 'fourth' }, - { label: 'Last', value: 'last' }, + { 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 })); @@ -64,7 +60,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [recurring, setRecurring] = useState(false); const [showConfig, setShowConfig] = useState(false); - const [repeatOption, setRepeatOption] = useState(repeatOptions[0]); + const [repeatOption, setRepeatOption] = useState({}); const [selectedDays, setSelectedDays] = useState([]); const [monthlyOption, setMonthlyOption] = useState('date'); const [selectedMonthlyDate, setSelectedMonthlyDate] = useState(1); @@ -75,6 +71,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { const [intervalMonths, setIntervalMonths] = useState(1); const [loadingData, setLoadingData] = useState(false); const [updatingData, setUpdatingData] = useState(false); + const [scheduleData, setScheduleData] = useState({}); const handleChange = (checked: boolean) => { if (!task.id) return; @@ -92,6 +89,7 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { if (selected) setRepeatOption(selected); } dispatch(updateRecurringChange(schedule)); + dispatch(setTaskRecurringSchedule({ schedule_id: schedule.id as string, task_id: task.id })); setRecurring(checked); if (!checked) setShowConfig(false); @@ -112,35 +110,119 @@ const TaskDrawerRecurringConfig = ({ task }: { task: ITaskViewModel }) => { setSelectedDays(checkedValues as unknown as string[]); }; - const handleSave = () => { - // Compose the schedule data and call the update handler - const data = { - recurring, - repeatOption, - selectedDays, - monthlyOption, - selectedMonthlyDate, - selectedMonthlyWeek, - selectedMonthlyDay, - intervalDays, - intervalWeeks, - intervalMonths, + const getSelectedDays = () => { + return daysOfWeek + .filter(day => day.checked) // Get only the checked days + .map(day => day.value); // Extract their numeric values + } + + const getUpdateBody = () => { + if (!task.id || !task.schedule_id || !repeatOption.value) return; + + const body: ITaskRecurringSchedule = { + id: task.id, + schedule_type: repeatOption.value }; - // if (onUpdateSchedule) onUpdateSchedule(data); - setShowConfig(false); + + switch (repeatOption.value) { + case ITaskRecurring.Weekly: + body.days_of_week = getSelectedDays(); + break; + + case ITaskRecurring.Monthly: + if (monthlyOption === 'date') { + body.date_of_month = selectedMonthlyDate; + setSelectedMonthlyDate(0); + setSelectedMonthlyDay(0); + } else { + body.week_of_month = selectedMonthlyWeek; + body.day_of_month = selectedMonthlyDay; + setSelectedMonthlyDate(0); + } + break; + + case ITaskRecurring.EveryXDays: + body.interval_days = intervalDays; + break; + + case ITaskRecurring.EveryXWeeks: + body.interval_weeks = intervalWeeks; + break; + + case ITaskRecurring.EveryXMonths: + body.interval_months = intervalMonths; + break; + } + return body; + } + + const handleSave = async () => { + if (!task.id || !task.schedule_id) return; + + try { + setUpdatingData(true); + const body = getUpdateBody(); + + const res = await taskRecurringApiService.updateTaskRecurringData(task.schedule_id, body); + if (res.done) { + setShowConfig(false); + } + } catch (e) { + logger.error("handleSave", e); + } finally { + setUpdatingData(false); + } }; - const getScheduleData = () => {}; + const updateDaysOfWeek = () => { + for (let i = 0; i < daysOfWeek.length; i++) { + daysOfWeek[i].checked = scheduleData.days_of_week?.includes(daysOfWeek[i].value) ?? false; + } + }; + + const getScheduleData = async () => { + if (!task.schedule_id) return; + setLoadingData(true); + try { + const res = await taskRecurringApiService.getTaskRecurringData(task.schedule_id); + if (res.done) { + setScheduleData(res.body); + if (!res.body) { + setRepeatOption(repeatOptions[0]); + } else { + const selected = repeatOptions.find(e => e.value == res.body.schedule_type); + if (selected) { + setRepeatOption(selected); + setSelectedMonthlyDate(scheduleData.date_of_month || 1); + setSelectedMonthlyDay(scheduleData.day_of_month || 0); + setSelectedMonthlyWeek(scheduleData.week_of_month || 0); + setIntervalDays(scheduleData.interval_days || 1); + setIntervalWeeks(scheduleData.interval_weeks || 1); + setIntervalMonths(scheduleData.interval_months || 1); + setMonthlyOption(selectedMonthlyDate ? 'date' : 'day'); + updateDaysOfWeek(); + } + } + }; + } catch (e) { + logger.error("getScheduleData", e); + } + finally { + setLoadingData(false); + } + } const handleResponse = (response: ITaskRecurringScheduleData) => { if (!task || !response.task_id) return; }; useEffect(() => { + if (!task) return; + if (task) setRecurring(!!task.schedule_id); if (recurring) void getScheduleData(); socket?.on(SocketEvents.TASK_RECURRING_CHANGE.toString(), handleResponse); - }, []); + }, [task]); return (
diff --git a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts index 9654a2d0..74ba350c 100644 --- a/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts +++ b/worklenz-frontend/src/features/task-drawer/task-drawer.slice.ts @@ -105,6 +105,15 @@ const taskDrawerSlice = createSlice({ }>) => { state.timeLogEditing = action.payload; }, + setTaskRecurringSchedule: (state, action: PayloadAction<{ + schedule_id: string; + task_id: string; + }>) => { + const { schedule_id, task_id } = action.payload; + if (state.taskFormViewModel?.task && state.taskFormViewModel.task.id === task_id) { + state.taskFormViewModel.task.schedule_id = schedule_id; + } + }, }, extraReducers: builder => { builder.addCase(fetchTask.pending, state => { @@ -133,5 +142,6 @@ export const { setTaskLabels, setTaskSubscribers, setTimeLogEditing, + setTaskRecurringSchedule } = taskDrawerSlice.actions; export default taskDrawerSlice.reducer; diff --git a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts index 8fc708d5..190b6e7f 100644 --- a/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts +++ b/worklenz-frontend/src/types/tasks/task-recurring-schedule.ts @@ -1,19 +1,37 @@ +export enum ITaskRecurring { + Daily = 'daily', + Weekly = 'weekly', + Monthly = 'monthly', + EveryXDays = 'every_x_days', + EveryXWeeks = 'every_x_weeks', + EveryXMonths = 'every_x_months' +} + export interface ITaskRecurringSchedule { - type: 'daily' | 'weekly' | 'monthly' | 'interval'; - dayOfWeek?: number; // 0 = Sunday, 1 = Monday, ..., 6 = Saturday (for weekly tasks) - dayOfMonth?: number; // 1 - 31 (for monthly tasks) - weekOfMonth?: number; // 1 = 1st week, 2 = 2nd week, ..., 5 = Last week (for monthly tasks) - hour: number; // Time of the day in 24-hour format - minute: number; // Minute of the hour - interval?: { - days?: number; // Interval in days (for every x days) - weeks?: number; // Interval in weeks (for every x weeks) - months?: number; // Interval in months (for every x months) - }; + created_at?: string; + day_of_month?: number | null; + date_of_month?: number | null; + days_of_week?: number[] | null; + id?: string; // UUID v4 + interval_days?: number | null; + interval_months?: number | null; + interval_weeks?: number | null; + schedule_type?: ITaskRecurring; + week_of_month?: number | null; +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string } export interface ITaskRecurringScheduleData { - task_id?: string, - id?: string, - schedule_type?: string -} \ No newline at end of file + task_id?: string, + id?: string, + schedule_type?: string +} + +export interface IRepeatOption { + value?: ITaskRecurring + label?: string +}