diff --git a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js index 62527c0119..5e696b6bfc 100644 --- a/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js +++ b/packages/central-server/src/apiV2/utilities/constructNewRecordValidationRules.js @@ -450,50 +450,8 @@ export const constructForSingle = (models, recordType) => { entity_id: [constructRecordExistsWithId(models.entity)], survey_id: [constructRecordExistsWithId(models.survey)], assignee_id: [constructIsEmptyOr(constructRecordExistsWithId(models.user))], - due_date: [ - (value, { repeat_schedule: repeatSchedule }) => { - if (repeatSchedule) { - if (value) { - throw new Error('Recurring tasks must not have a due date'); - } - return true; - } - if (!value) throw new Error('Due date is required for non-recurring tasks'); - return true; - }, - ], - repeat_schedule: [ - (value, { due_date: dueDate }) => { - // If the task has a due date, the repeat schedule is empty - if (dueDate) { - if (value) { - throw new Error('Non-recurring tasks must not have a repeat schedule'); - } - return true; - } - - if (!value) { - throw new Error('Repeat schedule is required for recurring tasks'); - } - return true; - }, - ], - status: [ - (value, { repeat_schedule: repeatSchedule }) => { - // If the task is recurring, the status is empty - if (repeatSchedule) { - if (value) { - throw new Error('Recurring tasks cannot have a status'); - } - return true; - } - - if (!value) { - throw new Error('Status is required for non-recurring tasks'); - } - return true; - }, - ], + due_date: [hasContent], + status: [hasContent], }; default: diff --git a/packages/central-server/src/index.js b/packages/central-server/src/index.js index ae5ab8867b..508d4c0046 100644 --- a/packages/central-server/src/index.js +++ b/packages/central-server/src/index.js @@ -25,7 +25,7 @@ import { startSyncWithMs1 } from './ms1'; import { startSyncWithKoBo } from './kobo'; import { startFeedScraper } from './social'; import { createApp } from './createApp'; -import { TaskOverdueChecker } from './scheduledTasks'; +import { TaskOverdueChecker, RepeatingTaskDueDateHandler } from './scheduledTasks'; import winston from './log'; import { configureEnv } from './configureEnv'; @@ -74,6 +74,7 @@ configureEnv(); * Scheduled tasks */ new TaskOverdueChecker(models).init(); + new RepeatingTaskDueDateHandler(models).init(); /** * Set up actual app with routes etc. diff --git a/packages/central-server/src/scheduledTasks/RepeatingTaskDueDateHandler.js b/packages/central-server/src/scheduledTasks/RepeatingTaskDueDateHandler.js index e69de29bb2..81793f8717 100644 --- a/packages/central-server/src/scheduledTasks/RepeatingTaskDueDateHandler.js +++ b/packages/central-server/src/scheduledTasks/RepeatingTaskDueDateHandler.js @@ -0,0 +1,40 @@ +/* + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import winston from 'winston'; +import { getNextOccurrence } from '@tupaia/utils'; +import { ScheduledTask } from './ScheduledTask'; + +export class RepeatingTaskDueDateHandler extends ScheduledTask { + constructor(models) { + // run RepeatingTaskDueDateHandler every hour + super(models, 'RepeatingTaskDueDateHandler', '0 * * * *'); + } + + async run() { + const { task } = this.models; + // find all repeating tasks that have passed their current due date + const repeatingTasks = await task.find({ + task_status: 'repeating', + due_date: { comparator: '<', comparisonValue: new Date().getTime() }, + }); + + winston.info(`Found ${repeatingTasks.length} repeating task(s)`); + + // update the due date for each repeating task to the next occurrence + for (const repeatingTask of repeatingTasks) { + const { repeat_schedule: repeatSchedule } = repeatingTask; + + const nextDueDate = getNextOccurrence({ + ...repeatSchedule, + dtstart: new Date(repeatSchedule.dtstart), // convert string to date because rrule.js expects a Date object + }); + + repeatingTask.due_date = nextDueDate; + await repeatingTask.save(); + + winston.info(`Updated due date for task ${repeatingTask.id} to ${nextDueDate}`); + } + } +} diff --git a/packages/central-server/src/scheduledTasks/index.js b/packages/central-server/src/scheduledTasks/index.js index 33d1e3b1df..edcece7bc4 100644 --- a/packages/central-server/src/scheduledTasks/index.js +++ b/packages/central-server/src/scheduledTasks/index.js @@ -4,3 +4,4 @@ */ export { TaskOverdueChecker } from './TaskOverdueChecker'; +export { RepeatingTaskDueDateHandler } from './RepeatingTaskDueDateHandler'; diff --git a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js index b79c48623a..22ee62fc98 100644 --- a/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js +++ b/packages/central-server/src/tests/apiV2/taskComments/CreateTaskComment.test.js @@ -12,6 +12,7 @@ import { } from '@tupaia/database'; import { TestableApp, resetTestData } from '../../testUtilities'; import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; describe('Permissions checker for CreateTaskComment', async () => { const BES_ADMIN_POLICY = { @@ -101,7 +102,9 @@ describe('Permissions checker for CreateTaskComment', async () => { entity_id: facilities[1].id, assignee_id: assignee.id, due_date: null, - repeat_schedule: '{}', + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, status: null, }, ]; diff --git a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js index 6841945b15..ad78e5bf0c 100644 --- a/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/EditTask.test.js @@ -10,6 +10,7 @@ import { findOrCreateDummyRecord, generateId, } from '@tupaia/database'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; import { TestableApp, resetTestData } from '../../testUtilities'; import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; @@ -100,6 +101,7 @@ describe('Permissions checker for EditTask', async () => { survey_id: surveys[0].survey.id, entity_id: facilities[0].id, due_date: dueDate, + repeat_schedule: null, status: 'to_do', }, { @@ -108,6 +110,7 @@ describe('Permissions checker for EditTask', async () => { entity_id: facilities[1].id, assignee_id: assignee.id, due_date: dueDate, + repeat_schedule: null, status: 'to_do', }, ]; @@ -212,20 +215,21 @@ describe('Permissions checker for EditTask', async () => { describe('System generated comments', () => { it('Adds a comment when the due date changes on a task', async () => { + const newDate = new Date('2025-11-30').getTime(); await app.grantAccess({ DL: ['Donor'], TO: ['Donor'], }); await app.put(`tasks/${tasks[1].id}`, { body: { - due_date: new Date('2021-11-30').getTime(), + due_date: newDate, }, }); const comment = await models.taskComment.findOne({ task_id: tasks[1].id, type: models.taskComment.types.System, - message: 'Changed due date from 31 December 21 to 30 November 21', + message: 'Changed due date from 31 December 21 to 30 November 25', }); expect(comment).not.to.be.null; }); @@ -240,7 +244,7 @@ describe('Permissions checker for EditTask', async () => { // this is currently null when setting a task to repeat due_date: null, repeat_schedule: { - frequency: 'daily', + freq: RRULE_FREQUENCIES.DAILY, }, }, }); @@ -269,7 +273,7 @@ describe('Permissions checker for EditTask', async () => { await app.put(`tasks/${tasks[1].id}`, { body: { repeat_schedule: { - frequency: 'daily', + freq: RRULE_FREQUENCIES.DAILY, }, }, }); diff --git a/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js index 998f8937b3..2a93dce501 100644 --- a/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js +++ b/packages/central-server/src/tests/apiV2/tasks/GETTasks.test.js @@ -10,6 +10,7 @@ import { findOrCreateDummyRecord, generateId, } from '@tupaia/database'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; import { TestableApp, resetTestData } from '../../testUtilities'; import { BES_ADMIN_PERMISSION_GROUP } from '../../../permissions'; @@ -104,7 +105,9 @@ describe('Permissions checker for GETTasks', async () => { entity_id: facilities[1].id, assignee_id: assignee.id, due_date: null, - repeat_schedule: '{}', + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, status: null, }, { @@ -113,7 +116,9 @@ describe('Permissions checker for GETTasks', async () => { entity_id: facilities[0].id, assignee_id: assignee.id, due_date: null, - repeat_schedule: '{}', + repeat_schedule: { + freq: RRULE_FREQUENCIES.DAILY, + }, status: null, }, ]; diff --git a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js index d24e0e1ae2..b73c5fc25e 100644 --- a/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js +++ b/packages/database/src/__tests__/changeHandlers/TaskCompletionHandler.test.js @@ -141,9 +141,11 @@ describe('TaskCompletionHandler', () => { entity_id: samoa.id, survey_id: SURVEY.id, created_at: '2024-07-08', + due_date: new Date('2024-07-25').getTime(), status: null, repeat_schedule: { - frequency: 'daily', + freq: 1, + dtstart: '2024-07-08', }, }); @@ -155,6 +157,11 @@ describe('TaskCompletionHandler', () => { survey_response_id: responses[0], entity_id: samoa.id, parent_task_id: repeatTask.id, + due_date: new Date('2024-07-25').getTime(), + repeat_schedule: { + freq: 1, + dtstart: '2024-07-08', + }, }); await assertTaskStatus(newTask.id, 'completed', responses[0]); }); @@ -166,8 +173,10 @@ describe('TaskCompletionHandler', () => { survey_id: SURVEY.id, created_at: '2024-07-08', status: 'to_do', + due_date: new Date('2024-07-08').getTime(), repeat_schedule: { - frequency: 'daily', + freq: 1, + dtstart: '2024-07-08', }, }); @@ -178,6 +187,11 @@ describe('TaskCompletionHandler', () => { survey_response_id: responses[0], entity_id: fiji.id, parent_task_id: repeatTask.id, + due_date: new Date('2024-07-08').getTime(), + repeat_schedule: { + freq: 1, + dtstart: '2024-07-08', + }, }); await assertTaskStatus(newTask.id, 'completed', responses[0]); }); diff --git a/packages/database/src/modelClasses/Task.js b/packages/database/src/modelClasses/Task.js index 49983ff90c..dc793f9789 100644 --- a/packages/database/src/modelClasses/Task.js +++ b/packages/database/src/modelClasses/Task.js @@ -4,6 +4,7 @@ */ import { format } from 'date-fns'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; import { DatabaseModel } from '../DatabaseModel'; import { DatabaseRecord } from '../DatabaseRecord'; import { RECORDS } from '../records'; @@ -47,17 +48,22 @@ const formatValue = async (field, value, models) => { return assignee.full_name; } case 'repeat_schedule': { - if (!value || !value?.frequency) { + if (!value || value.freq === undefined || value.freq === null) { return "Doesn't repeat"; } - return `${value.frequency.charAt(0).toUpperCase()}${value.frequency.slice(1)}`; + const frequency = Object.keys(RRULE_FREQUENCIES).find( + key => RRULE_FREQUENCIES[key] === value.freq, + ); + + if (!frequency) { + return "Doesn't repeat"; + } + + // Convert the frequency to a more human-friendly format, e.g. 'DAILY' -> 'Daily' + return `${frequency.charAt(0)}${frequency.slice(1).toLowerCase()}`; } case 'due_date': { - // TODO: Currently repeating tasks don't have a due date, so we need to handle null values. In RN-1341 we will add a due date to repeating tasks overnight, so this will need to be updated then - if (!value) { - return 'No due date'; - } // Format the date as 'd MMMM yy' (e.g. 1 January 21). This is so that there is no ambiguity between US and other date formats return format(new Date(value), 'd MMMM yy'); } @@ -126,6 +132,7 @@ export class TaskRecord extends DatabaseRecord { entity_id: entityId, repeat_schedule: repeatSchedule, assignee_id: assigneeId, + due_date: dueDate, id, } = this; @@ -144,35 +151,36 @@ export class TaskRecord extends DatabaseRecord { repeat_schedule: repeatSchedule, status: 'completed', survey_response_id: surveyResponseId, + due_date: dueDate, parent_task_id: id, }; // Check for an existing task so that multiple tasks aren't created for the same survey response const existingTask = await this.model.findOne(where); - if (!existingTask) { - const newTask = await this.model.create(where); - await newTask.addComment( - 'Completed this task', - commentUserId, - this.otherModels.taskComment.types.System, - ); - await this.addComment( - `Completed task ${newTask.id}`, - commentUserId, - this.otherModels.taskComment.types.System, - ); - } - } else { - await this.model.updateById(id, { - status: 'completed', - survey_response_id: surveyResponseId, - }); + + if (existingTask) return; + const newTask = await this.model.create(where); + await newTask.addComment( + 'Completed this task', + commentUserId, + this.otherModels.taskComment.types.System, + ); await this.addComment( 'Completed this task', commentUserId, this.otherModels.taskComment.types.System, ); + return; } + await this.model.updateById(id, { + status: 'completed', + survey_response_id: surveyResponseId, + }); + await this.addComment( + 'Completed this task', + commentUserId, + this.otherModels.taskComment.types.System, + ); } /** @@ -242,12 +250,10 @@ export class TaskRecord extends DatabaseRecord { const originalValue = this[field]; // If the field hasn't actually changed, don't add a comment if (originalValue === newValue) continue; - - // If the due date is changed and the task is repeating, don't add a comment, because this just means that the repeat schedule is being updated, not that the due date is being changed. This will likely change as part of RN-1341 - // TODO: re-evaulate this when RN-1341 is implemented - if (field === 'due_date' && updatedFields.repeat_schedule) { - continue; - } + // Don't add a comment when repeat schedule is updated and the frequency is the same + if (field === 'repeat_schedule' && originalValue?.freq === newValue?.freq) continue; + // Don't add a comment when due date is updated for repeat schedule + if (field === 'due_date' && this.repeat_schedule) continue; const friendlyFieldName = getFriendlyFieldName(field); const formattedOriginalValue = await formatValue(field, originalValue, this.otherModels); diff --git a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts index eebaabf582..79d3170852 100644 --- a/packages/datatrak-web-server/src/routes/EditTaskRoute.ts +++ b/packages/datatrak-web-server/src/routes/EditTaskRoute.ts @@ -21,11 +21,12 @@ export type EditTaskRequest = Request< export class EditTaskRoute extends Route { public async buildResponse() { - const { body, ctx, params } = this.req; + const { body, ctx, params, models } = this.req; const { taskId } = params; + const originalTask = await models.task.findById(taskId); - const taskDetails = formatTaskChanges(body); + const taskDetails = formatTaskChanges(body, originalTask); return ctx.services.central.updateResource(`tasks/${taskId}`, {}, taskDetails); } diff --git a/packages/datatrak-web-server/src/routes/TasksRoute.ts b/packages/datatrak-web-server/src/routes/TasksRoute.ts index eccedc1ef7..f021b86bca 100644 --- a/packages/datatrak-web-server/src/routes/TasksRoute.ts +++ b/packages/datatrak-web-server/src/routes/TasksRoute.ts @@ -75,11 +75,7 @@ export class TasksRoute extends Route { } if (id === 'repeat_schedule') { - this.filters[id] = { - comparator: 'ilike', - comparisonValue: `${value}%`, - castAs: 'text', - }; + this.filters[`repeat_schedule->freq`] = value; return; } this.filters[id] = { diff --git a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts index cdf4df6089..d9590558be 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskChanges.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskChanges.ts @@ -3,7 +3,9 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ +import { isNotNullish } from '@tupaia/tsutils'; import { DatatrakWebTaskChangeRequest, Task } from '@tupaia/types'; +import { generateRRule } from '@tupaia/utils'; type Input = Partial & Partial>; @@ -12,26 +14,44 @@ type Output = Partial & { comment?: string; }; -export const formatTaskChanges = (task: Input) => { - const { due_date: dueDate, repeat_schedule: repeatSchedule, assignee, ...restOfTask } = task; +const convertDateToEndOfDay = (date: Date | number) => { + const dateObj = new Date(date); + const endOfDay = new Date(dateObj.setHours(23, 59, 59, 999)); + return endOfDay; +}; + +export const formatTaskChanges = (task: Input, originalTask?: Task) => { + const { due_date: dueDate, repeat_frequency: frequency, assignee, ...restOfTask } = task; const taskDetails: Output = restOfTask; - if (repeatSchedule) { - // if task is repeating, clear due date - taskDetails.repeat_schedule = { - // TODO: format this correctly when recurring tasks are implemented - frequency: repeatSchedule, - }; - taskDetails.due_date = null; - } else if (dueDate) { - // apply status and due date only if not a repeating task - const unix = new Date(dueDate).getTime(); + if (isNotNullish(frequency)) { + // if there is no due date to use, use the original task's due date (this will be the case when editing a task's repeat schedule without changing the due date) + const dueDateToUse = dueDate || originalTask?.due_date; + // if there is no due date to use, throw an error - this should never happen but is a safety check + if (!dueDateToUse) { + throw new Error('Must have a due date'); + } + const endOfDay = convertDateToEndOfDay(dueDateToUse); + // if task is repeating, generate rrule + const rrule = generateRRule(endOfDay, frequency); + // set repeat_schedule to the original options object so we can use it to generate next occurrences and display the schedule + taskDetails.repeat_schedule = rrule.origOptions; + } - taskDetails.due_date = unix; + // if frequency is explicitly set to null, set repeat_schedule to null + if (frequency === null) { taskDetails.repeat_schedule = null; } + // if there is a due date, convert it to unix + if (dueDate) { + const endOfDay = convertDateToEndOfDay(dueDate); + const unix = new Date(endOfDay).getTime(); + + taskDetails.due_date = unix; + } + if (assignee !== undefined) { taskDetails.assignee_id = assignee?.value ?? null; } diff --git a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts index 367925827f..2971c57a1a 100644 --- a/packages/datatrak-web-server/src/utils/formatTaskResponse.ts +++ b/packages/datatrak-web-server/src/utils/formatTaskResponse.ts @@ -6,7 +6,7 @@ import { Country, DatatrakWebTasksRequest, Entity, Survey, Task, TaskStatus } from '@tupaia/types'; import camelcaseKeys from 'camelcase-keys'; -export type TaskT = Omit & { +export type TaskT = Omit & { 'entity.name': Entity['name']; 'entity.parent_name': Entity['name']; 'entity.country_code': Country['code']; diff --git a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx index 42d24694a9..27f2852ca3 100644 --- a/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx +++ b/packages/datatrak-web/src/features/Tasks/CreateTaskModal/CreateTaskModal.tsx @@ -106,6 +106,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { survey_code: null, entity_id: null, due_date: defaultDueDate, + repeat_frequency: null, repeat_schedule: null, assignee: null, }; @@ -262,7 +263,7 @@ export const CreateTaskModal = ({ onClose }: CreateTaskModalProps) => { }} /> ( diff --git a/packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx b/packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx index 5130e0aebd..8346382bef 100644 --- a/packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx +++ b/packages/datatrak-web/src/features/Tasks/RepeatScheduleInput.tsx @@ -4,57 +4,8 @@ */ import React, { useEffect } from 'react'; import { FormControl } from '@material-ui/core'; -import { format, lastDayOfMonth } from 'date-fns'; import { Autocomplete } from '../../components'; - -export const getRepeatScheduleOptions = dueDate => { - const noRepeat = { - label: "Doesn't repeat", - value: '', - }; - - if (!dueDate) { - return [noRepeat]; - } - - const dueDateObject = new Date(dueDate); - - const dayOfWeek = format(dueDateObject, 'EEEE'); - const dateOfMonth = format(dueDateObject, 'do'); - - const month = format(dueDateObject, 'MMMM'); - - const lastDateOfMonth = format(lastDayOfMonth(dueDateObject), 'do'); - - const isLastDayOfMonth = dateOfMonth === lastDateOfMonth; - - // If the due date is the last day of the month, we don't need to show the date, just always repeat on the last day. Otherwise, show the date. - // In the case of February, if the selected date is, for example, the 29th/30th/31st of June, we would repeat on the last day of the month. - const monthlyOption = isLastDayOfMonth - ? 'Monthly on the last day' - : `Monthly on the ${dateOfMonth}`; - - // TODO: When saving, add some logic here when we handle recurring tasks - return [ - noRepeat, - { - label: 'Daily', - value: 'daily', - }, - { - label: `Weekly on ${dayOfWeek}`, - value: 'weekly', - }, - { - label: monthlyOption, - value: 'monthly', - }, - { - label: `Yearly on ${dateOfMonth} of ${month}`, - value: 'yearly', - }, - ]; -}; +import { getRepeatScheduleOptions } from './utils'; interface RepeatScheduleInputProps { value: string; @@ -88,7 +39,7 @@ export const RepeatScheduleInput = ({ return ( { return onChange(newValue?.value ?? null); diff --git a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx index ac17c723cb..9875b89647 100644 --- a/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx +++ b/packages/datatrak-web/src/features/Tasks/TaskDetails/TaskDetails.tsx @@ -12,7 +12,7 @@ import { LoadingContainer } from '@tupaia/ui-components'; import { useEditTask, useSurveyResponse } from '../../../api'; import { displayDate } from '../../../utils'; import { Button as BaseButton, SurveyTickIcon, Tile } from '../../../components'; -import { SingleTaskResponse } from '../../../types'; +import { SingleTaskResponse } from '../../../types'; import { RepeatScheduleInput } from '../RepeatScheduleInput'; import { DueDatePicker } from '../DueDatePicker'; import { AssigneeInput } from '../AssigneeInput'; @@ -142,7 +142,7 @@ export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { const generateDefaultValues = (task: SingleTaskResponse) => { return { due_date: task.taskDueDate ?? null, - repeat_schedule: task.repeatSchedule?.frequency ?? null, + repeat_frequency: task.repeatSchedule?.freq ?? null, assignee: task.assignee?.id ? task.assignee : null, }; }; @@ -221,7 +221,7 @@ export const TaskDetails = ({ task }: { task: SingleTaskResponse }) => { ( { - // TODO: When repeating tasks are implemented, make sure the repeat schedule is displayed correctly once a due date is returned with the task - const repeatScheduleOptions = getRepeatScheduleOptions(task.taskDueDate); - const { label } = repeatScheduleOptions[0]; - if (!task.repeatSchedule?.frequency) { - return label; - } - const { frequency } = task.repeatSchedule; - const selectedOption = repeatScheduleOptions.find(option => option.value === frequency); - if (selectedOption) return selectedOption.label; - return label; -}; - export const TaskSummary = ({ task }: { task: SingleTaskResponse }) => { const displayRepeatSchedule = getDisplayRepeatSchedule(task); return ( diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/RepeatScheduleFilter.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/RepeatScheduleFilter.tsx new file mode 100644 index 0000000000..b838e86559 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/RepeatScheduleFilter.tsx @@ -0,0 +1,27 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React from 'react'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; +import { TaskFilterType } from '../../../types'; +import { SelectFilter } from './SelectFilter'; + +interface RepeatScheduleFilterProps { + onChange: (value: string) => void; + filter: { value: TaskFilterType } | undefined; +} + +export const RepeatScheduleFilter = ({ onChange, filter }: RepeatScheduleFilterProps) => { + // if there is a selected status filter and it is not 'repeating', no options should show + const options = Object.entries(RRULE_FREQUENCIES).map(([key, value]) => ({ + value, + //converts the key to a more readable format + label: `${key.charAt(0)}${key.slice(1).toLowerCase()}`, + })); + + return ( + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/SelectFilter.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/SelectFilter.tsx new file mode 100644 index 0000000000..6392011fe1 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/SelectFilter.tsx @@ -0,0 +1,100 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import React, { ReactNode, useRef } from 'react'; +import styled from 'styled-components'; +import { MenuItem as MuiMenuItem, Select as MuiSelect } from '@material-ui/core'; +import { KeyboardArrowDown } from '@material-ui/icons'; +import { TaskFilterType } from '../../../types'; + +const PlaceholderText = styled.span` + color: ${({ theme }) => theme.palette.text.secondary}; +`; + +const MenuItem = styled(MuiMenuItem)` + padding-inline: 0.5rem; + padding-block: 0.2rem; + margin-block: 0.2rem; +`; + +const Select = styled(MuiSelect)` + .MuiInputBase-input { + background: transparent; + } +`; + +const MenuItemText = styled.span` + font-size: 0.75rem; + padding: 0.3rem; +`; + +type Option = { + value: string | number; + label?: string; +}; + +interface SelectFilterProps { + onChange: (value: string) => void; + filter: { value: TaskFilterType } | undefined; + renderValue?: (value: Option['value']) => ReactNode; + options: Option[]; + placeholderValue?: string; + renderOption?: (option: Option) => ReactNode; +} + +export const SelectFilter = ({ + onChange, + filter, + renderValue, + options, + placeholderValue = 'Select', + renderOption, +}: SelectFilterProps) => { + const ref = useRef(null); + + const filterValue = filter?.value ?? ''; + + const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { + onChange(event.target.value as string); + if (ref.current) { + ref.current.blur(); + ref.current.classList.remove('Mui-focused'); + } + }; + + const selectedFilterValue = options.find(option => option.value === filterValue); + + const invalidFilterValue = !selectedFilterValue; + if (invalidFilterValue && filter?.value) { + onChange(''); + } + + return ( + + ); +}; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/StatusFilter.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/StatusFilter.tsx index 455a39fbc3..3dd24fc6af 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/StatusFilter.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/StatusFilter.tsx @@ -3,35 +3,12 @@ * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd */ -import React, { useRef } from 'react'; -import styled from 'styled-components'; +import React from 'react'; import { TaskStatus } from '@tupaia/types'; -import { MenuItem as MuiMenuItem, Select as MuiSelect } from '@material-ui/core'; -import { KeyboardArrowDown } from '@material-ui/icons'; import { STATUS_VALUES, StatusPill } from '../StatusPill'; import { getTaskFilterSetting } from '../../../utils'; import { TaskFilterType } from '../../../types'; - -const PlaceholderText = styled.span` - color: ${({ theme }) => theme.palette.text.secondary}; -`; - -const PlaceholderOption = styled(MuiMenuItem)` - font-size: 0.75rem; - padding-inline: 0.8rem; -`; - -const MenuItem = styled(MuiMenuItem)` - padding-inline: 0.5rem; - padding-block: 0.2rem; - margin-block: 0.2rem; -`; - -const Select = styled(MuiSelect)` - .MuiInputBase-input { - background: transparent; - } -`; +import { SelectFilter } from './SelectFilter'; interface StatusFilterProps { onChange: (value: string) => void; @@ -39,10 +16,8 @@ interface StatusFilterProps { } export const StatusFilter = ({ onChange, filter }: StatusFilterProps) => { - const ref = useRef(null); const includeCompletedTasks = getTaskFilterSetting('show_completed_tasks'); const includeCancelledTasks = getTaskFilterSetting('show_cancelled_tasks'); - const filterValue = filter?.value ?? ''; const options = Object.keys(STATUS_VALUES) .filter(value => { @@ -57,40 +32,14 @@ export const StatusFilter = ({ onChange, filter }: StatusFilterProps) => { }) .map(value => ({ value })); - const handleChange = (event: React.ChangeEvent<{ value: unknown }>) => { - onChange(event.target.value as string); - if (ref.current) { - ref.current.blur(); - ref.current.classList.remove('Mui-focused'); - } - }; - - const invalidFilterValue = !options.find(option => option.value === filterValue); - if (invalidFilterValue && filter?.value) { - onChange(''); - } - return ( - + } + placeholderValue="Show all" + renderOption={option => } + /> ); }; diff --git a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx index 1af11af088..ed6a1ae0bc 100644 --- a/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx +++ b/packages/datatrak-web/src/features/Tasks/TasksTable/TasksTable.tsx @@ -13,11 +13,13 @@ import { useCurrentUserContext, useTasks } from '../../../api'; import { displayDate } from '../../../utils'; import { DueDatePicker } from '../DueDatePicker'; import { StatusPill } from '../StatusPill'; +import { getDisplayRepeatSchedule } from '../utils'; import { TaskActionsMenu } from '../TaskActionsMenu'; import { CommentsCount } from '../CommentsCount'; import { StatusFilter } from './StatusFilter'; import { ActionButton } from './ActionButton'; import { FilterToolbar } from './FilterToolbar'; +import { RepeatScheduleFilter } from './RepeatScheduleFilter'; const Container = styled.div` display: flex; @@ -69,15 +71,19 @@ const useTasksTable = () => { }; const updateFilters = newFilters => { - const nonEmptyFilters = newFilters.filter(({ value }) => !!value); + const nonEmptyFilters = newFilters.filter( + ({ value }) => value !== null && value !== undefined && value !== '', + ); if (JSON.stringify(nonEmptyFilters) === JSON.stringify(filters)) return; if (nonEmptyFilters.length === 0) { searchParams.delete('filters'); setSearchParams(searchParams); return; } + searchParams.set('filters', JSON.stringify(nonEmptyFilters)); searchParams.set('page', '0'); + setSearchParams(searchParams); }; @@ -122,11 +128,12 @@ const useTasksTable = () => { }, { Header: 'Repeating task', - // TODO: Update this display once RN-1341 is done. Also handle sorting on this column in this issue. - accessor: row => (row.repeatSchedule ? JSON.stringify(row.repeatSchedule) : 'Doesn’t repeat'), + accessor: row => getDisplayRepeatSchedule(row), id: 'repeat_schedule', filterable: true, disableResizing: true, + Filter: RepeatScheduleFilter, + disableSortBy: true, width: 180, }, { diff --git a/packages/datatrak-web/src/features/Tasks/utils.ts b/packages/datatrak-web/src/features/Tasks/utils.ts new file mode 100644 index 0000000000..e6099ba247 --- /dev/null +++ b/packages/datatrak-web/src/features/Tasks/utils.ts @@ -0,0 +1,68 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +import { format, lastDayOfMonth } from 'date-fns'; +import { RRULE_FREQUENCIES } from '@tupaia/utils'; +import { SingleTaskResponse } from '../../types'; + +export const getRepeatScheduleOptions = dueDate => { + const noRepeat = { + label: "Doesn't repeat", + value: '', + }; + + if (!dueDate) { + return [noRepeat]; + } + + const dueDateObject = new Date(dueDate); + + const dayOfWeek = format(dueDateObject, 'EEEE'); + const dateOfMonth = format(dueDateObject, 'do'); + + const month = format(dueDateObject, 'MMMM'); + + const lastDateOfMonth = format(lastDayOfMonth(dueDateObject), 'do'); + + const isLastDayOfMonth = dateOfMonth === lastDateOfMonth; + + // If the due date is the last day of the month, we don't need to show the date, just always repeat on the last day. Otherwise, show the date. + // In the case of February, if the selected date is, for example, the 29th/30th/31st of June, we would repeat on the last day of the month. + const monthlyOption = isLastDayOfMonth + ? 'Monthly on the last day' + : `Monthly on the ${dateOfMonth}`; + + return [ + noRepeat, + { + label: 'Daily', + value: RRULE_FREQUENCIES.DAILY, + }, + { + label: `Weekly on ${dayOfWeek}`, + value: RRULE_FREQUENCIES.WEEKLY, + }, + { + label: monthlyOption, + value: RRULE_FREQUENCIES.MONTHLY, + }, + { + label: `Yearly on ${dateOfMonth} of ${month}`, + value: RRULE_FREQUENCIES.YEARLY, + }, + ]; +}; + +export const getDisplayRepeatSchedule = (task: SingleTaskResponse) => { + const repeatScheduleOptions = getRepeatScheduleOptions(task.taskDueDate); + const { label } = repeatScheduleOptions[0]; + if (!task.repeatSchedule) { + return label; + } + const { freq } = task.repeatSchedule; + const selectedOption = repeatScheduleOptions.find(option => option.value === freq); + if (selectedOption) return selectedOption.label; + return label; +}; diff --git a/packages/types/config/models/config.json b/packages/types/config/models/config.json index 0c8e04bc79..9cab7cfca1 100644 --- a/packages/types/config/models/config.json +++ b/packages/types/config/models/config.json @@ -23,7 +23,8 @@ "public.entity.attributes": "EntityAttributes", "public.user_account.preferences": "UserAccountPreferences", "public.dashboard_relation.entity_types": "EntityType[]", - "public.project.config": "ProjectConfig" + "public.project.config": "ProjectConfig", + "public.task.repeat_schedule": "RepeatSchedule" }, "typeMap": { "string": ["geography"], @@ -37,7 +38,8 @@ "MapOverlayConfig": "./models-extra", "EntityAttributes": "./models-extra", "UserAccountPreferences": "./models-extra", - "ProjectConfig": "./models-extra" + "ProjectConfig": "./models-extra", + "RepeatSchedule": "./models-extra" } } } diff --git a/packages/types/src/schemas/schemas.ts b/packages/types/src/schemas/schemas.ts index 2480eb20f2..50e00920fe 100644 --- a/packages/types/src/schemas/schemas.ts +++ b/packages/types/src/schemas/schemas.ts @@ -41891,6 +41891,50 @@ export const ProjectConfigSchema = { "additionalProperties": false } +export const RepeatScheduleSchema = { + "description": "Tupaia\nCopyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd", + "additionalProperties": false, + "type": "object", + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } +} + export const AccessRequestSchema = { "type": "object", "properties": { @@ -85915,8 +85959,47 @@ export const TaskSchema = { "type": "string" }, "repeat_schedule": { + "description": "Tupaia\nCopyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd", + "additionalProperties": false, "type": "object", - "properties": {} + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } }, "status": { "enum": [ @@ -85969,8 +86052,47 @@ export const TaskCreateSchema = { "type": "string" }, "repeat_schedule": { + "description": "Tupaia\nCopyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd", + "additionalProperties": false, "type": "object", - "properties": {} + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } }, "status": { "enum": [ @@ -86024,8 +86146,47 @@ export const TaskUpdateSchema = { "type": "string" }, "repeat_schedule": { + "description": "Tupaia\nCopyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd", + "additionalProperties": false, "type": "object", - "properties": {} + "properties": { + "freq": { + "type": "number" + }, + "interval": { + "type": "number" + }, + "bymonthday": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "bysetpos": { + "anyOf": [ + { + "type": "array", + "items": { + "type": "number" + } + }, + { + "type": "number" + } + ] + }, + "dtstart": { + "type": "string", + "format": "date-time" + } + } }, "status": { "enum": [ diff --git a/packages/types/src/types/index.ts b/packages/types/src/types/index.ts index 65e614ae82..0e17a62209 100644 --- a/packages/types/src/types/index.ts +++ b/packages/types/src/types/index.ts @@ -100,6 +100,7 @@ export { ProjectConfig, TaskQuestionConfig, UserQuestionConfig, + RepeatSchedule, } from './models-extra'; export * from './requests'; export * from './css'; diff --git a/packages/types/src/types/models-extra/index.ts b/packages/types/src/types/models-extra/index.ts index f599e0a50e..ae004653c7 100644 --- a/packages/types/src/types/models-extra/index.ts +++ b/packages/types/src/types/models-extra/index.ts @@ -110,3 +110,4 @@ export { VizPeriodGranularity, DashboardItemType } from './common'; export { isChartReport, isViewReport, isMatrixReport } from './report'; export { UserAccountPreferences } from './user'; export { ProjectConfig } from './project'; +export { RepeatSchedule } from './task'; diff --git a/packages/types/src/types/models-extra/task.ts b/packages/types/src/types/models-extra/task.ts new file mode 100644 index 0000000000..2beeb7737b --- /dev/null +++ b/packages/types/src/types/models-extra/task.ts @@ -0,0 +1,12 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ + +export type RepeatSchedule = Record & { + freq?: number; + interval?: number; + bymonthday?: number | number[] | null; + bysetpos?: number | number[] | null; + dtstart?: Date | null; +}; diff --git a/packages/types/src/types/models.ts b/packages/types/src/types/models.ts index 7b87d5c075..6a103b87fd 100644 --- a/packages/types/src/types/models.ts +++ b/packages/types/src/types/models.ts @@ -13,6 +13,7 @@ import { MapOverlayConfig } from './models-extra'; import { EntityAttributes } from './models-extra'; import { UserAccountPreferences } from './models-extra'; import { ProjectConfig } from './models-extra'; +import { RepeatSchedule } from './models-extra'; export interface AccessRequest { 'approved'?: boolean | null; @@ -1542,7 +1543,7 @@ export interface Task { 'initial_request_id'?: string | null; 'overdue_email_sent'?: Date | null; 'parent_task_id'?: string | null; - 'repeat_schedule'?: {} | null; + 'repeat_schedule'?: RepeatSchedule | null; 'status'?: TaskStatus | null; 'survey_id': string; 'survey_response_id'?: string | null; @@ -1555,7 +1556,7 @@ export interface TaskCreate { 'initial_request_id'?: string | null; 'overdue_email_sent'?: Date | null; 'parent_task_id'?: string | null; - 'repeat_schedule'?: {} | null; + 'repeat_schedule'?: RepeatSchedule | null; 'status'?: TaskStatus | null; 'survey_id': string; 'survey_response_id'?: string | null; @@ -1569,7 +1570,7 @@ export interface TaskUpdate { 'initial_request_id'?: string | null; 'overdue_email_sent'?: Date | null; 'parent_task_id'?: string | null; - 'repeat_schedule'?: {} | null; + 'repeat_schedule'?: RepeatSchedule | null; 'status'?: TaskStatus | null; 'survey_id'?: string; 'survey_response_id'?: string | null; diff --git a/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts index c192ad1ab4..9df6643d4e 100644 --- a/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts +++ b/packages/types/src/types/requests/datatrak-web-server/TaskChangeRequest.ts @@ -4,15 +4,17 @@ */ import { Survey, Task } from '../../models'; +import { RepeatSchedule } from '../../models-extra'; export type Params = Record; export type ResBody = { message: string; }; export type ReqQuery = Record; -export type ReqBody = Partial> & { +export type ReqBody = Partial> & { survey_code: Survey['code']; comment?: string; + repeat_frequency?: RepeatSchedule['freq']; assignee?: { value: string; label: string; diff --git a/packages/utils/package.json b/packages/utils/package.json index c0c9047208..cf4c242c4c 100644 --- a/packages/utils/package.json +++ b/packages/utils/package.json @@ -39,6 +39,7 @@ "node-fetch": "^1.7.3", "numeral": "^2.0.6", "prop-types": "^15.6.2", + "rrule": "^2.8.1", "sanitize-filename": "^1.6.3", "validator": "^13.11.0", "winston": "^3.3.3", diff --git a/packages/utils/src/__tests__/rrule.test.js b/packages/utils/src/__tests__/rrule.test.js new file mode 100644 index 0000000000..272fcd9b93 --- /dev/null +++ b/packages/utils/src/__tests__/rrule.test.js @@ -0,0 +1,131 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { RRule } from 'rrule'; +import { + RRULE_FREQUENCIES, + generateDailyRRule, + generateMonthlyRRule, + generateRRule, + generateWeeklyRRule, + generateYearlyRRule, + getNextOccurrence, +} from '../rrule'; + +describe('RRule', () => { + it('generateDailyRRule should return an RRule that will repeat daily from the given start date', () => { + const rrule = generateDailyRRule(new Date('2021-01-01')); + expect(rrule).toEqual( + new RRule({ + freq: RRule.DAILY, + dtstart: new Date('2021-01-01'), + interval: 1, + }), + ); + expect(rrule.all()[0]).toEqual(new Date('2021-01-01')); + expect(rrule.all()[1]).toEqual(new Date('2021-01-02')); + }); + + it('generateWeeklyRRule should return an RRule that will repeat weekly from the given start date', () => { + const startDate = new Date('2021-01-01'); + const rrule = generateWeeklyRRule(startDate); + expect(rrule).toEqual( + new RRule({ + freq: RRule.WEEKLY, + dtstart: startDate, + interval: 1, + }), + ); + expect(rrule.all()[0]).toEqual(startDate); + expect(rrule.all()[1]).toEqual(new Date('2021-01-08')); + }); + + it('generateMonthlyRRule should return an RRule that will repeat monthly from the given start date', () => { + const startDate = new Date('2021-01-01'); + const rrule = generateMonthlyRRule(startDate); + expect(rrule).toEqual( + new RRule({ + freq: RRule.MONTHLY, + dtstart: startDate, + bymonthday: [startDate.getDate()], + interval: 1, + }), + ); + expect(rrule.all()[0]).toEqual(startDate); + expect(rrule.all()[1]).toEqual(new Date('2021-02-01')); + }); + + it('generateMonthlyRRule should return an RRule that will repeat monthly on the last day of the month if the given start date is the last day of that month', () => { + const startDate = new Date('2021-01-31'); + const rrule = generateMonthlyRRule(startDate); + expect(rrule).toEqual( + new RRule({ + freq: RRule.MONTHLY, + dtstart: startDate, + bymonthday: [-1], + interval: 1, + }), + ); + expect(rrule.all()[0]).toEqual(startDate); + expect(rrule.after(new Date('2021-01-31'))).toEqual(new Date('2021-02-28')); + expect(rrule.after(new Date('2021-04-01'))).toEqual(new Date('2021-04-30')); + expect(rrule.after(new Date('2024-01-31'))).toEqual(new Date('2024-02-29')); + }); + + it('generateMonthlyRRule should return an RRule that will repeat monthly on either the 30th or the last day of the month for February if the given start date is the 30th', () => { + const startDate = new Date('2021-01-30'); + const rrule = generateMonthlyRRule(startDate); + expect(rrule).toEqual( + new RRule({ + freq: RRule.MONTHLY, + dtstart: startDate, + bymonthday: [28, 29, 30], + bysetpos: -1, + interval: 1, + }), + ); + expect(rrule.all()[0]).toEqual(startDate); + expect(rrule.all()[1]).toEqual(new Date('2021-02-28')); + expect(rrule.after(new Date('2024-01-31'))).toEqual(new Date('2024-02-29')); + }); + + it('generateYearlyRRule should return an RRule that will repeat yearly from the given start date', () => { + const startDate = new Date('2021-01-30'); + const rrule = generateYearlyRRule(startDate); + expect(rrule).toEqual( + new RRule({ + freq: RRule.YEARLY, + dtstart: startDate, + interval: 1, + }), + ); + expect(rrule.all()[0]).toEqual(startDate); + expect(rrule.all()[1]).toEqual(new Date('2022-01-30')); + }); + + it('generateRRule should return an RRule using the start date and frequency', () => { + const startDate = new Date('2021-01-30'); + const rrule = generateRRule(startDate, RRULE_FREQUENCIES.YEARLY); + expect(rrule).toEqual( + new RRule({ + freq: RRule.YEARLY, + dtstart: startDate, + interval: 1, + }), + ); + }); + + it('getNextOccurrence should return the next occurrence for a specific rrule', () => { + expect( + getNextOccurrence( + { + freq: RRule.YEARLY, + dtstart: new Date('2021-01-30'), + interval: 1, + }, + new Date('2022-01-19'), + ), + ).toEqual(new Date('2022-01-30')); + }); +}); diff --git a/packages/utils/src/index.js b/packages/utils/src/index.js index 6f041c377a..198ca04b71 100644 --- a/packages/utils/src/index.js +++ b/packages/utils/src/index.js @@ -42,4 +42,5 @@ export { createClassExtendingProxy } from './proxy'; export { fetchPatiently } from './fetchPatiently'; export { oneSecondSleep, sleep } from './sleep'; export { getUniqueSurveyQuestionFileName } from './getUniqueSurveyQuestionFileName'; +export * from './rrule'; export { formatDateInTimezone, getOffsetForTimezone } from './timezone'; diff --git a/packages/utils/src/rrule.js b/packages/utils/src/rrule.js new file mode 100644 index 0000000000..66ba916103 --- /dev/null +++ b/packages/utils/src/rrule.js @@ -0,0 +1,113 @@ +/** + * Tupaia + * Copyright (c) 2017 - 2024 Beyond Essential Systems Pty Ltd + */ +import { Frequency, RRule } from 'rrule'; +import { isLastDayOfMonth } from 'date-fns'; + +export const RRULE_FREQUENCIES = { + DAILY: Frequency.DAILY, + WEEKLY: Frequency.WEEKLY, + MONTHLY: Frequency.MONTHLY, + YEARLY: Frequency.YEARLY, +}; + +export const generateRRule = (startDate, frequency) => { + switch (frequency) { + case RRULE_FREQUENCIES.DAILY: + return generateDailyRRule(startDate); + case RRULE_FREQUENCIES.WEEKLY: + return generateWeeklyRRule(startDate); + case RRULE_FREQUENCIES.MONTHLY: + return generateMonthlyRRule(startDate); + case RRULE_FREQUENCIES.YEARLY: + return generateYearlyRRule(startDate); + default: + throw new Error(`Invalid frequency: ${frequency}`); + } +}; + +/** + * + * @param {Date} startDate + * @returns {RRule} RRule that will repeat daily from the given start date + */ +export const generateDailyRRule = startDate => { + return new RRule({ + freq: RRule.DAILY, + dtstart: startDate, + interval: 1, + }); +}; + +/** + * + * @param {Date} startDate + * @returns {RRule} RRule that will repeat weekly on the given days of the week from the given start date + */ +export const generateWeeklyRRule = startDate => { + return new RRule({ + freq: RRule.WEEKLY, + dtstart: startDate, + interval: 1, + }); +}; + +/** + * + * @param {Date} startDate + * @returns {RRule} RRule that will repeat monthly on the same day of the month as the given start date + */ +export const generateMonthlyRRule = startDate => { + const dayOfMonth = startDate.getDate(); + + if (dayOfMonth <= 28) { + return new RRule({ + freq: RRule.MONTHLY, + dtstart: startDate, + interval: 1, + bymonthday: [dayOfMonth], + }); + } + + // If the day of the month is the last day of the month, return a rule that will be applied to the last day of the month every month + if (isLastDayOfMonth(startDate)) { + return new RRule({ + freq: RRule.MONTHLY, + dtstart: startDate, + interval: 1, + bymonthday: [-1], + }); + } + + // Get the days of the month to check - if the given date is the 30th, this will include 28, 29, 30. If the given date is the 29th, this will include 28, 29 + const bymonthday = dayOfMonth === 30 ? [28, 29, 30] : [28, 29]; + + return new RRule({ + freq: RRule.MONTHLY, + dtstart: startDate, + interval: 1, + bymonthday, + // This will get the last available date from the bymonthday array. For example, if the dayOfMonth is 30, the bymonthday array will be [28, 29, 30] and the bysetpos will be -1, which will get the 30th day of the month if it exists, otherwise the 28th or 29th (for February) + bysetpos: -1, + }); +}; + +/** + * + * @param {Date} startDate + * @returns {RRule} RRule that will repeat yearly on the same day of the year as the given start date + */ +export const generateYearlyRRule = startDate => { + return new RRule({ + freq: RRule.YEARLY, + dtstart: startDate, + interval: 1, + }); +}; + +export const getNextOccurrence = (rruleOptions, startDate = new Date()) => { + const rrule = new RRule(rruleOptions); + const nextOccurrence = rrule.after(startDate, true); + return nextOccurrence; +}; diff --git a/yarn.lock b/yarn.lock index 86e9a750cb..daeb4dc328 100644 --- a/yarn.lock +++ b/yarn.lock @@ -12758,6 +12758,7 @@ __metadata: npm-run-all: ^4.1.5 numeral: ^2.0.6 prop-types: ^15.6.2 + rrule: ^2.8.1 sanitize-filename: ^1.6.3 validator: ^13.11.0 winston: ^3.3.3 @@ -38495,6 +38496,15 @@ __metadata: languageName: node linkType: hard +"rrule@npm:^2.8.1": + version: 2.8.1 + resolution: "rrule@npm:2.8.1" + dependencies: + tslib: ^2.4.0 + checksum: 77f3750d90f7ec76f2435d4e0b5f860e20b94d8b0cedf2e94803f8050f8f7e3898153db9790cd4e3aaaf0c2db99b0a912e955fbd0200368ffd4318ec3bc0c88f + languageName: node + linkType: hard + "rsvp@npm:^4.8.4": version: 4.8.5 resolution: "rsvp@npm:4.8.5"