From 0521647e6eee452bee9c8488691c03798e53f921 Mon Sep 17 00:00:00 2001 From: Kenn Sippell Date: Mon, 2 Oct 2023 20:41:44 -0700 Subject: [PATCH 1/3] Recurring tasks --- package-lock.json | 14 ++ package.json | 3 +- .../validate-declarative-schema.js | 25 ++- src/nools/.eslintrc | 2 +- src/nools/definition-preparation.js | 7 +- src/nools/task-emitter.js | 162 +++++++++----- src/nools/task-recurring.js | 136 +++++++++++ .../validate-declarative-schema.spec.js | 33 +++ test/nools/mocks.js | 3 +- test/nools/task-emitter.spec.js | 89 ++++++-- test/nools/task-recurring.spec.js | 211 ++++++++++++++++++ 11 files changed, 599 insertions(+), 86 deletions(-) create mode 100644 src/nools/task-recurring.js create mode 100644 test/nools/task-recurring.spec.js diff --git a/package-lock.json b/package-lock.json index 3b533b13a..905606162 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "json-diff": "^0.5.4", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", + "luxon": "^3.4.3", "mime-types": "^2.1.32", "minimist": "^1.2.5", "mkdirp": "^1.0.4", @@ -9636,6 +9637,14 @@ "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", "dev": true }, + "node_modules/luxon": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", + "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", + "engines": { + "node": ">=12" + } + }, "node_modules/make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", @@ -28032,6 +28041,11 @@ "integrity": "sha512-AI2r85+4MquTw9ZYqabu4nMwy9Oftlfa/e/52t9IjtfG+mGBbTNdAoZ3RQKLHR6r0wQnwZnPIEh/Ya6XTWAKNA==", "dev": true }, + "luxon": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", + "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==" + }, "make-dir": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-2.1.0.tgz", diff --git a/package.json b/package.json index d6a162646..6b5839216 100644 --- a/package.json +++ b/package.json @@ -35,7 +35,6 @@ }, "homepage": "https://github.com/medic/cht-conf#readme", "dependencies": { - "joi": "^17.9.2", "@medic/translation-checker": "^1.0.1", "@parcel/watcher": "^2.0.5", "@xmldom/xmldom": "^0.8.2", @@ -46,9 +45,11 @@ "eslint-loader": "^3.0.4", "googleapis": "^84.0.0", "iso-639-1": "^2.1.9", + "joi": "^17.9.2", "json-diff": "^0.5.4", "json-stringify-safe": "^5.0.1", "json2csv": "^4.5.4", + "luxon": "^3.4.3", "mime-types": "^2.1.32", "minimist": "^1.2.5", "mkdirp": "^1.0.4", diff --git a/src/lib/compilation/validate-declarative-schema.js b/src/lib/compilation/validate-declarative-schema.js index 51c39f8bb..8e3c4fa4f 100644 --- a/src/lib/compilation/validate-declarative-schema.js +++ b/src/lib/compilation/validate-declarative-schema.js @@ -82,10 +82,10 @@ const TargetSchema = joi.array().items( .unique('id') .required(); -const EventSchema = idPresence => joi.object({ +const NonRecurringEventSchema = idPresence => joi.object({ id: joi.string().presence(idPresence), days: joi.alternatives().conditional('dueDate', { is: joi.exist(), then: joi.forbidden(), otherwise: joi.number().required() }) - .error(taskError('"event.days" is a required integer field only when "event.dueDate" is absent')), + .error(taskError('"event.days" is a required integer field but only when "event.dueDate" is absent')), dueDate: joi.alternatives().conditional('days', { is: joi.exist(), then: joi.forbidden(), otherwise: joi.function().required() }) .error(taskError('"event.dueDate" is required to be "function(event, contact, report)" only when "event.days" is absent')), start: joi.number().min(0).required(), @@ -117,11 +117,22 @@ const TaskSchema = joi.array().items( 'should define property "resolvedIf" as: function(contact, report) { ... }.' ) ), - events: joi.alternatives().conditional('events', { - is: joi.array().length(1), - then: joi.array().items(EventSchema('optional')).min(1).required(), - otherwise: joi.array().items(EventSchema('required')).unique('id').required(), - }), + events: joi.alternatives().try( + joi.object({ + recurringStartDate: joi.alternatives().conditional('period', { is: joi.number().greater(1), then: joi.date().required(), otherwise: joi.date().optional() }) + .error(taskError('"event.recurringStartDate" is a required date field when "event.period" is longer than daily')), + recurringEndDate: joi.date().optional(), + start: joi.number().min(0).optional(), + end: joi.number().min(0).optional(), + period: joi.number().min(1).optional(), + periodUnit: joi.string().valid('day', 'days', 'month', 'months').optional(), + }), + joi.alternatives().conditional('events', { + is: joi.array().length(1), + then: joi.array().items(NonRecurringEventSchema('optional')).min(1).required(), + otherwise: joi.array().items(NonRecurringEventSchema('required')).unique('id').required(), + }), + ).required(), priority: joi.alternatives().try( joi.object({ level: joi.string().valid('high', 'medium').optional(), diff --git a/src/nools/.eslintrc b/src/nools/.eslintrc index d75451451..d117d00d6 100644 --- a/src/nools/.eslintrc +++ b/src/nools/.eslintrc @@ -5,7 +5,7 @@ }, "root": true, "parserOptions": { - "ecmaVersion": 5 + "ecmaVersion": 6 }, "extends": "eslint:recommended", "rules": { diff --git a/src/nools/definition-preparation.js b/src/nools/definition-preparation.js index e11d2f4b4..76fc6a4d6 100644 --- a/src/nools/definition-preparation.js +++ b/src/nools/definition-preparation.js @@ -1,3 +1,5 @@ +const Luxon = require('luxon'); + /* Declarative tasks and targets (the elements exported by partner task.js and target.js files), are complex objects containing functions. Definition-preparation.js binds a value for `this` in all the functions within a definition. @@ -16,7 +18,10 @@ function bindAllFunctionsToContext(obj, context) { var key = keys[i]; switch(typeof obj[key]) { case 'object': - bindAllFunctionsToContext(obj[key], context); + const isLuxon = Luxon.Duration.isDuration(obj[key]) || Luxon.DateTime.isDateTime(obj[key]); + if (!isLuxon) { + bindAllFunctionsToContext(obj[key], context); + } break; case 'function': obj[key] = obj[key].bind(context); diff --git a/src/nools/task-emitter.js b/src/nools/task-emitter.js index d6a2d9d2d..219d46e4c 100644 --- a/src/nools/task-emitter.js +++ b/src/nools/task-emitter.js @@ -1,12 +1,12 @@ -var prepareDefinition = require('./definition-preparation'); -var taskDefaults = require('./task-defaults'); +const prepareDefinition = require('./definition-preparation'); +const taskDefaults = require('./task-defaults'); +const emitRecurringEvents = require('./task-recurring'); function taskEmitter(taskDefinitions, c, Utils, Task, emit) { if (!taskDefinitions) return; - var taskDefinition, r; - for (var idx1 = 0; idx1 < taskDefinitions.length; ++idx1) { - taskDefinition = Object.assign({}, taskDefinitions[idx1], taskDefaults); + for (let idx1 = 0; idx1 < taskDefinitions.length; ++idx1) { + const taskDefinition = Object.assign({}, taskDefinitions[idx1], taskDefaults); if (typeof taskDefinition.resolvedIf !== 'function') { taskDefinition.resolvedIf = function (contact, report, event, dueDate) { return taskDefinition.defaultResolvedIf(contact, report, event, dueDate, Utils); @@ -14,17 +14,25 @@ function taskEmitter(taskDefinitions, c, Utils, Task, emit) { } prepareDefinition(taskDefinition); + const emitterContext = { + taskDefinition, + c, + Utils, + Task, + emit, + }; + switch (taskDefinition.appliesTo) { case 'reports': case 'scheduled_tasks': - for (var idx2=0; idx2 { + emitTaskEvent(emitterContext, partialEmission); + }); + } + + function emitEventsArray(emitterContext, scheduledTaskIdx) { + const { Utils } = emitterContext; + const result = []; + let dueDate = null; + for (let i = 0; i < taskDefinition.events.length; i++) { + const event = taskDefinition.events[i]; if (event.dueDate) { dueDate = event.dueDate(event, c, r, scheduledTaskIdx); @@ -105,58 +133,84 @@ function emitTasks(taskDefinition, Utils, Task, emit, c, r) { if (event.dueDate) { dueDate = event.dueDate(event, c); } else { - var defaultDueDate = c.contact && c.contact.reported_date ? new Date(c.contact.reported_date) : new Date(); + const defaultDueDate = c.contact && c.contact.reported_date ? new Date(c.contact.reported_date) : new Date(); dueDate = new Date(Utils.addDate(defaultDueDate, event.days)); } } - if (!Utils.isTimely(dueDate, event)) { - continue; - } - - task = { - // One task instance for each event per form that triggers a task, not per contact - // Otherwise they collide when contact has multiple reports of the same form - _id: (r ? r._id : c.contact && c.contact._id) + '~' + (event.id || i) + '~' + taskDefinition.name, - deleted: !!((c.contact && c.contact.deleted) || r ? r.deleted : false), - doc: c, - contact: obtainContactLabelFromSchedule(taskDefinition, c, r), - icon: taskDefinition.icon, + const uuidPrefix = r ? r._id : c.contact && c.contact._id; + result.push({ + _id: `${uuidPrefix}~${event.id || i}~${taskDefinition.name}`, date: dueDate, - readyStart: event.start || 0, - readyEnd: event.end || 0, - title: taskDefinition.title, - resolved: taskDefinition.resolvedIf(c, r, event, dueDate, scheduledTaskIdx), - actions: initActions(taskDefinition.actions, event), - }; + event, + }); + } - if (scheduledTaskIdx !== undefined) { - task._id += '-' + scheduledTaskIdx; - } + return result; + } - priority = taskDefinition.priority; - if (typeof priority === 'function') { - priority = priority(c, r); - } + function emitTaskEvent(emitterContext, partialEmission, scheduledTaskIdx) { + const { taskDefinition, Utils, c, r, emit, Task } = emitterContext; - if (priority) { - task.priority = priority.level; - task.priorityLabel = priority.label; - } + if (!partialEmission._id) { + throw 'partialEmission._id'; + } + + if (!partialEmission.date) { + throw 'partialEmission.date'; + } - emit('task', new Task(task)); + if (!partialEmission.event) { + throw 'partialEmission.event'; + } + + const { event, date: dueDate } = partialEmission; + if (!Utils.isTimely(dueDate, event)) { + return; + } + + const defaultEmission = { + // One task instance for each event per form that triggers a task, not per contact + // Otherwise they collide when contact has multiple reports of the same form + deleted: !!((c.contact && c.contact.deleted) || r ? r.deleted : false), + doc: c, + contact: obtainContactLabelFromSchedule(taskDefinition, c, r), + icon: taskDefinition.icon, + readyStart: event.start || 0, + readyEnd: event.end || 0, + title: taskDefinition.title, + resolved: taskDefinition.resolvedIf(c, r, event, dueDate, scheduledTaskIdx), + actions: initActions(taskDefinition.actions, event), + }; + + if (scheduledTaskIdx !== undefined) { + defaultEmission._id += '-' + scheduledTaskIdx; + } + + let priority = taskDefinition.priority; + if (typeof priority === 'function') { + priority = priority(c, r); + } + + if (priority) { + defaultEmission.priority = priority.level; + defaultEmission.priorityLabel = priority.label; } + + const emission = Object.assign({}, defaultEmission, partialEmission); + delete emission.event; + emit('task', new Task(emission)); } function initActions(actions, event) { - return taskDefinition.actions.map(function(action) { + return actions.map(function(action) { return initAction(action, event); }); } function initAction(action, event) { - var appliesToReport = !!r; - var content = { + const appliesToReport = !!r; + const content = { source: 'task', source_id: appliesToReport ? r._id : c.contact && c.contact._id, contact: c.contact, diff --git a/src/nools/task-recurring.js b/src/nools/task-recurring.js new file mode 100644 index 000000000..784f8360f --- /dev/null +++ b/src/nools/task-recurring.js @@ -0,0 +1,136 @@ +const { DateTime, Duration, Interval } = require('luxon'); + +const DEFAULT_PERIOD = 1; +const DEFAULT_PERIOD_UNIT = 'day'; + +function getulateRecurringEvents(emitterContext) { + const { name, events } = emitterContext.taskDefinition; + + const timelyInterval = getTimelyInterval(); + const recurringInterval = getRecurringInterval(events); + const criteriaInterval = getCriteriaInterval(recurringInterval, timelyInterval, events); + + // an invalid interval results if it ends before it starts + if (!criteriaInterval || !criteriaInterval.isValid) { + return []; + } + + const periodDuration = getPeriodAsDuration(events); + const iterationInterval = getIterationInterval(events, recurringInterval, criteriaInterval, periodDuration); + + let dueDateIterator = iterationInterval.start; + const result = []; + while (dueDateIterator < iterationInterval.end) { + const taskInterval = getTaskInterval(dueDateIterator, events); + if (criteriaInterval.overlaps(taskInterval)) { + const uuidPrefix = emitterContext.r ? emitterContext.r._id : emitterContext.c.contact && emitterContext.c.contact._id; + result.push({ + _id: `${uuidPrefix}~recurring~${dueDateIterator.toISODate()}~${name}`, + date: dueDateIterator.toMillis(), + event: events, + }); + } + + dueDateIterator = dueDateIterator.plus(periodDuration); + } + + return result; +} + +function getTimelyInterval() { + // "Timely" is terminology used by the tasks engine for the time interval within task emissions will get converted into task documents + // https://github.com/medic/cht-core/blob/master/shared-libs/rules-engine/src/task-states.js#L20 + return Interval.fromDateTimes( + DateTime.now().startOf('day').plus({ days: -30 }), + DateTime.now().endOf('day').plus({ days: 60 }) + ); +} + +function getRecurringInterval(events) { + const START_OF_TIME = DateTime.fromISO('1000-01-01'); + const END_OF_TIME = DateTime.fromISO('3000-01-01'); + const start = userInputToDateTime(events.recurringStartDate) || START_OF_TIME; + const end = userInputToDateTime(events.recurringEndDate) || END_OF_TIME; + return Interval.fromDateTimes(start.startOf('day'), end.endOf('day')); +} + +function getCriteriaInterval(recurringInterval, timelyInterval, events) { + const expandedTimelyInterval = Interval.fromDateTimes( + timelyInterval.start.plus({ days: -events.end || 0 }), + timelyInterval.end.plus({ days: events.start || 0 }), + ); + return recurringInterval.intersection(expandedTimelyInterval); +} + +function getPeriodAsDuration(events) { + const measure = events.period || DEFAULT_PERIOD; + const unit = events.periodUnit || DEFAULT_PERIOD_UNIT; + + // enforced by joi: allowed units and measure > 1 + if (measure < 1) { + // this causes really bad things, so just in case this is a duplicate assertion + throw new Error(`Invalid event parameter "period": Values must be 1 or larger`); + } + + const periodDuration = Duration.fromObject({ [unit]: measure }); + if (!periodDuration.isValid) { + throw Error(`Invalid event parameter "period": ${periodDuration.invalidExplanation}`); + } + + const periodInDays = periodDuration.shiftTo('days').days; + if (periodInDays > 1 && !events.recurringStartDate) { + throw Error('Event parameter "recurringStartDate" is required when period is longer than 1 day'); + } + + return periodDuration; +} + +function getIterationInterval(events, recurringInterval, criteriaInterval, period) { + const advanceDateTimeUsingPeriod = (from, to, roundToFloor = true) => { + const aggregator = roundToFloor ? Math.floor : Math.ceil; + const periodUnit = Object.keys(period.toObject())[0]; + const durationToAdvance = to.diff(from, periodUnit); + const periodsToAdvance = aggregator(durationToAdvance[periodUnit] / period[periodUnit]); + return from.plus({ [periodUnit]: periodsToAdvance * period[periodUnit] }); + }; + + // the visibility interval (events.start) must impact the iteration window, but periodic information should be maintained + const startWithoutPeriod = criteriaInterval.start.plus({ days: -events.start || 0 }); + const endWithoutPeriod = criteriaInterval.end.plus({ days: events.end || 0 }); + + return Interval.fromDateTimes( + advanceDateTimeUsingPeriod(recurringInterval.start, startWithoutPeriod), + advanceDateTimeUsingPeriod(recurringInterval.end, endWithoutPeriod, false), + ); +} + +function getTaskInterval(dueDate, events) { + const start = dueDate.plus({ days: -events.start || 0 }).startOf('day'); + const end = dueDate.plus({ days: events.end || 0 }).endOf('day'); + return Interval.fromDateTimes(start, end); +} + +function userInputToDateTime(input) { + if (!input) { + return; + } + + let result; + if (DateTime.isDateTime(input)) { + result = input; + } else if (typeof input === 'object') { + result = DateTime.fromJSDate(input); + } else if (typeof input === 'string') { + result = DateTime.fromISO(input, { locale: 'en-US' }); + } else { + throw Error('Invalid event parameter: Expected type is Date(), Luxon DateTime(), or ISO string'); + } + + if (!result.isValid) { + throw Error(`Invalid event parameter: ${result.invalidExplanation}`); + } + + return result; +} + +module.exports = getulateRecurringEvents; diff --git a/test/lib/compilation/validate-declarative-schema.spec.js b/test/lib/compilation/validate-declarative-schema.spec.js index 143e32fa7..386b579fe 100644 --- a/test/lib/compilation/validate-declarative-schema.spec.js +++ b/test/lib/compilation/validate-declarative-schema.spec.js @@ -123,6 +123,39 @@ describe('validate-declarative-schema', () => { ]); }); + it('nominal recurring event', () => { + const aTask = buildTaskWithAction('report', 'home_visit'); + aTask.events = { recurringStartDate: '2020-01-01', period: 3 }; + + // when + const actual = validate([aTask], TaskSchema); + + // then + expect(actual).to.be.empty; + }); + + it('recurring cannot have negative period', () => { + const aTask = buildTaskWithAction('report', 'home_visit'); + aTask.events = { recurringStartDate: '2020-01-01', period: -1 }; + + // when + const actual = validate([aTask], TaskSchema); + + // then + expect(actual).to.have.property('length', 1); + }); + + it('recurring constrains required units', () => { + const aTask = buildTaskWithAction('report', 'home_visit'); + aTask.events = { recurringStartDate: new Date('2020-01-01'), period: 1, periodUnit: 'years' }; + + // when + const actual = validate([aTask], TaskSchema); + + // then + expect(actual).to.have.property('length', 1); + }); + it('array.unique internal', () => { const schema = joi.array().items(joi.object({ event: joi.array().items(joi.object()).unique('id'), diff --git a/test/nools/mocks.js b/test/nools/mocks.js index b28747199..387a8a5e4 100644 --- a/test/nools/mocks.js +++ b/test/nools/mocks.js @@ -1,5 +1,6 @@ let idCounter; -const TEST_DATE = 1431143098575; +const TEST_DATE = 1431143098575; // 2015-05-09T03:44:58.575Z + // make the tests work in any timezone. TODO it's not clear if this is a hack, // or actually correct. see https://github.com/medic/cht-core/issues/4928 const TEST_DAY = new Date(TEST_DATE); diff --git a/test/nools/task-emitter.spec.js b/test/nools/task-emitter.spec.js index ae86e37fd..9025abecf 100644 --- a/test/nools/task-emitter.spec.js +++ b/test/nools/task-emitter.spec.js @@ -1,5 +1,7 @@ const chai = require('chai'); +const { DateTime } = require('luxon'); const sinon = require('sinon'); + const runNoolsLib = require('../run-nools-lib'); const { TEST_DATE, @@ -399,7 +401,7 @@ describe('task-emitter', () => { const [event] = config.tasks[0].events; delete event.days; - const spy = sinon.spy(); + const spy = sinon.stub().returns(12345); event.dueDate = spy; // when @@ -630,28 +632,26 @@ describe('task-emitter', () => { expect(invoked).to.be.true; }); - describe('scheduled-task based', () => { - it('???', () => { // FIXME this test needs a proper name - // given - const config = { - c: personWithReports(aReportWithScheduledTasks(5)), - targets: [], - tasks: [ aScheduledTaskBasedTask() ], - }; + it('scheduled-task based task', () => { + // given + const config = { + c: personWithReports(aReportWithScheduledTasks(5)), + targets: [], + tasks: [ aScheduledTaskBasedTask() ], + }; - // when - const { emitted } = runNoolsLib(config); + // when + const { emitted } = runNoolsLib(config); - // then - assert.shallowDeepEqual(emitted, [ - { _type:'task', date:TEST_DAY }, - { _type:'task', date:TEST_DAY }, - { _type:'task', date:TEST_DAY }, - { _type:'task', date:TEST_DAY }, - { _type:'task', date:TEST_DAY }, - { _type:'_complete', _id: true }, - ]); - }); + // then + assert.shallowDeepEqual(emitted, [ + { _type:'task', date:TEST_DAY }, + { _type:'task', date:TEST_DAY }, + { _type:'task', date:TEST_DAY }, + { _type:'task', date:TEST_DAY }, + { _type:'task', date:TEST_DAY }, + { _type:'_complete', _id: true }, + ]); }); describe('invalid task type', () => { @@ -712,6 +712,53 @@ describe('task-emitter', () => { }); }); + describe('recurring task events', () => { + beforeEach(() => { + const testDate = new Date(TEST_DATE); // 2015-05-09 + sinon.useFakeTimers(testDate); + }); + + afterEach(() => { + sinon.reset(); + }); + + it('daily recurring task yields 91 task emissions', () => { + // given + const task = aPersonBasedTask(); + task.events = { + recurringStartDate: DateTime.fromISO('2015-01-01'), + recurringEndDate: DateTime.fromISO('2024-01-01'), + }; + + const config = { + c: personWithoutReports(), + targets: [], + tasks: [task], + utilsMock, + }; + + // when + const { emitted } = runNoolsLib(config); + + // then + const MS_IN_DAY = 24*60*60*1000; + expect(emitted.length).to.eq(92); // 30 prior, 1 today, 60 after, 1 completion event + assert.shallowDeepEqual(emitted[0], { + _type: 'task', + date: TEST_DAY.getTime() - 30 * MS_IN_DAY, + resolved: false, + contact: { _id: config.c.contact._id }, + actions:[ { form:'example-form' } ], + }); + assert.shallowDeepEqual(emitted[90], { + _type: 'task', + date: TEST_DAY.getTime() + 60 * MS_IN_DAY, + resolved: false, + contact: { _id: config.c.contact._id }, + actions:[ { form: 'example-form' } ], + }); + }); + }); }); }); diff --git a/test/nools/task-recurring.spec.js b/test/nools/task-recurring.spec.js new file mode 100644 index 000000000..1a27c4ea1 --- /dev/null +++ b/test/nools/task-recurring.spec.js @@ -0,0 +1,211 @@ +const { expect } = require('chai'); +const { DateTime } = require('luxon'); +const sinon = require('sinon'); + +const mocks = require('./mocks'); +const taskRecurring = require('../../src/nools/task-recurring'); + +describe('task-recurring', () => { + beforeEach(() => { + mocks.reset(); + }); + afterEach(() => { + sinon.reset(); + }); + + const dailyFebruaryEvents = { + recurringStartDate: '2023-02-01', + recurringEndDate: '2023-02-28', + }; + + const scenarios = [ + { + name: '0 tasks before timely window', + events: dailyFebruaryEvents, + today: '2022-12-02', + expectCount: 0, + }, + { + name: '1 task when timely window exactly 60 days before recurring start', + events: dailyFebruaryEvents, + today: '2022-12-03', + expectCount: 1, + }, + { + name: '28 tasks in middle of feb recurring window', + events: dailyFebruaryEvents, + today: '2022-12-03', + expectCount: 1, + }, + { + name: '1 tasks when timely window exactly 30 days after recurring end', + events: dailyFebruaryEvents, + today: '2023-03-30', + expectCount: 1, + }, + { + name: '0 tasks after timely window', + events: dailyFebruaryEvents, + today: '2023-03-30', + expectCount: 1, + }, + { + name: '0 tasks if recurring window ends before it starts', + events: { + recurringStartDate: '2023-02-11', + recurringEndDate: '2023-02-10', + }, + today: '2023-02-10', + expectCount: 0, + }, + { + name: '1 tasks when recurring window starts and end same day', + events: { + recurringStartDate: '2023-02-10', + recurringEndDate: '2023-02-10', + }, + today: '2023-01-10', + expectCount: 1, + }, + { + name: '91 daily tasks when no recurring window', + events: {}, + today: '2023-01-10', + expectCount: 91, + }, + { + name: '91 daily tasks for large recurring window', + events: { + recurringStartDate: '1799-01-01', + recurringEndDate: '2100-01-01', + }, + today: '2023-01-10', + expectCount: 91, + }, + { + name: '30 tasks for 3 day period', + events: { + recurringStartDate: '1799-01-01', + period: 3, + }, + today: '2023-01-11', + expectCount: 30, + expect: emissions => { + expect(emissions[0]._id).to.eq('c-2~recurring~2022-12-13~task-1'); + } + }, + { + name: '1 task for yearly task within feb', + events: { + recurringStartDate: '1799-02-01', + period: 12, + periodUnit: 'months', + }, + expectCount: 1, + }, + + { + name: '2nd day of the month', + events: { + recurringStartDate: '1799-01-02', + recurringEndDate: DateTime.fromISO('2500-01-01'), + periodUnit: 'month', + }, + today: '2023-02-10', + expectCount: 3, + expect: emissions => { + expect(emissions.map(e => e._id)).to.deep.eq([ + 'c-2~recurring~2023-02-02~task-1', + 'c-2~recurring~2023-03-02~task-1', + 'c-2~recurring~2023-04-02~task-1', + ]); + } + }, + { + name: 'feb 29th', + events: { + recurringStartDate: DateTime.fromISO('2000-02-29').toJSDate(), + period: 2, + start: 3, + end: 5, + periodUnit: 'month', + }, + today: '2024-02-29', + expectCount: 2, + expect: emissions => { + expect(emissions.map(e => e._id)).to.deep.eq([ + 'c-2~recurring~2024-02-29~task-1', + 'c-2~recurring~2024-04-29~task-1', // 28 is bug, 29 works. who cares? + ]); + } + }, + + { + name: 'start larger than timely window', + events: { + recurringStartDate: '2021-01-01', + start: 365 - 60 + 1, + periodUnit: 'months', + }, + today: '2020-01-01', + expectCount: 1, + }, + { + name: 'end larger than timely window', + events: { + recurringEndDate: '2020-01-01', + end: 366 - 31 /* dec */ + 1, + periodUnit: 'days', + }, + today: '2021-01-01', + expectCount: 1, + }, + ]; + + const invalidScenarios = [ + { + name: 'recurringStartDate invalid DateTime', + events: { recurringStartDate: DateTime.fromISO('abc') }, + expectError: 'parsed', + }, + { + name: 'numeric recurringStartDate is invalid', + events: { recurringStartDate: 5 }, + expectError: 'Date', + }, + { + name: 'recurringEndDate invalid date string', + events: { recurringEndDate: 'abc' }, + expectError: 'parsed', + }, + { + name: 'period invalid duration', + events: { period: -1 }, + expectError: '1 or larger', + }, + ]; + + for (const scenario of [...scenarios, ...invalidScenarios]) { + it(scenario.name, () => { + const today = DateTime.fromISO(scenario.today || '2020-01-01'); + sinon.useFakeTimers(today.toJSDate()); + + const taskDefinition = mocks.aPersonBasedTask(); + taskDefinition.events = scenario.events; + const context = { + taskDefinition, + c: mocks.personWithReports(), + }; + + if (scenario.expectError) { + expect(() => taskRecurring(context)).to.throw(scenario.expectError); + } else { + const emissions = taskRecurring(context); + expect(emissions).to.have.property('length', scenario.expectCount); + if (scenario.expect) { + scenario.expect(emissions); + } + } + }); + } +}); From 8213dd4ac0710ea944f76b512866429b4c1083f2 Mon Sep 17 00:00:00 2001 From: Kenn Sippell Date: Mon, 2 Oct 2023 21:00:25 -0700 Subject: [PATCH 2/3] Eslint --- src/nools/definition-preparation.js | 4 ++-- src/nools/task-emitter.js | 37 ++++++++++++++--------------- src/nools/task-recurring.js | 4 ++-- 3 files changed, 22 insertions(+), 23 deletions(-) diff --git a/src/nools/definition-preparation.js b/src/nools/definition-preparation.js index 76fc6a4d6..5a2de1c22 100644 --- a/src/nools/definition-preparation.js +++ b/src/nools/definition-preparation.js @@ -1,4 +1,4 @@ -const Luxon = require('luxon'); +const luxon = require('luxon'); /* Declarative tasks and targets (the elements exported by partner task.js and target.js files), are complex objects containing functions. @@ -18,7 +18,7 @@ function bindAllFunctionsToContext(obj, context) { var key = keys[i]; switch(typeof obj[key]) { case 'object': - const isLuxon = Luxon.Duration.isDuration(obj[key]) || Luxon.DateTime.isDateTime(obj[key]); + var isLuxon = luxon.Duration.isDuration(obj[key]) || luxon.DateTime.isDateTime(obj[key]); if (!isLuxon) { bindAllFunctionsToContext(obj[key], context); } diff --git a/src/nools/task-emitter.js b/src/nools/task-emitter.js index 219d46e4c..2746bef66 100644 --- a/src/nools/task-emitter.js +++ b/src/nools/task-emitter.js @@ -1,6 +1,6 @@ const prepareDefinition = require('./definition-preparation'); const taskDefaults = require('./task-defaults'); -const emitRecurringEvents = require('./task-recurring'); +const getRecurringTasks = require('./task-recurring'); function taskEmitter(taskDefinitions, c, Utils, Task, emit) { if (!taskDefinitions) return; @@ -25,8 +25,8 @@ function taskEmitter(taskDefinitions, c, Utils, Task, emit) { switch (taskDefinition.appliesTo) { case 'reports': case 'scheduled_tasks': - for (let idx2=0; idx2 { - emitTaskEvent(emitterContext, partialEmission); + emissionInfo.forEach(emission => { + emitTaskEvent(emitterContext, emission); }); } @@ -149,22 +148,22 @@ function emitTaskDefinition(emitterContext) { return result; } - function emitTaskEvent(emitterContext, partialEmission, scheduledTaskIdx) { + function emitTaskEvent(emitterContext, emissionInfo, scheduledTaskIdx) { const { taskDefinition, Utils, c, r, emit, Task } = emitterContext; - if (!partialEmission._id) { - throw 'partialEmission._id'; + if (!emissionInfo._id) { + throw 'emissionInfo._id'; } - if (!partialEmission.date) { - throw 'partialEmission.date'; + if (!emissionInfo.date) { + throw 'emissionInfo.date'; } - if (!partialEmission.event) { - throw 'partialEmission.event'; + if (!emissionInfo.event) { + throw 'emissionInfo.event'; } - const { event, date: dueDate } = partialEmission; + const { event, date: dueDate } = emissionInfo; if (!Utils.isTimely(dueDate, event)) { return; } @@ -197,7 +196,7 @@ function emitTaskDefinition(emitterContext) { defaultEmission.priorityLabel = priority.label; } - const emission = Object.assign({}, defaultEmission, partialEmission); + const emission = Object.assign({}, defaultEmission, emissionInfo); delete emission.event; emit('task', new Task(emission)); } diff --git a/src/nools/task-recurring.js b/src/nools/task-recurring.js index 784f8360f..7a35491f4 100644 --- a/src/nools/task-recurring.js +++ b/src/nools/task-recurring.js @@ -57,7 +57,7 @@ function getRecurringInterval(events) { function getCriteriaInterval(recurringInterval, timelyInterval, events) { const expandedTimelyInterval = Interval.fromDateTimes( timelyInterval.start.plus({ days: -events.end || 0 }), - timelyInterval.end.plus({ days: events.start || 0 }), + timelyInterval.end.plus({ days: events.start || 0 }) ); return recurringInterval.intersection(expandedTimelyInterval); } @@ -100,7 +100,7 @@ function getIterationInterval(events, recurringInterval, criteriaInterval, perio return Interval.fromDateTimes( advanceDateTimeUsingPeriod(recurringInterval.start, startWithoutPeriod), - advanceDateTimeUsingPeriod(recurringInterval.end, endWithoutPeriod, false), + advanceDateTimeUsingPeriod(recurringInterval.end, endWithoutPeriod, false) ); } From 8c6e2fa0e90b161f2ad463666ab0602134784730 Mon Sep 17 00:00:00 2001 From: "kennsippell@gmail.com" Date: Mon, 2 Oct 2023 23:20:10 -0700 Subject: [PATCH 3/3] Some commenty comments --- src/nools/task-recurring.js | 40 +++++++++++++++++++++++-------------- 1 file changed, 25 insertions(+), 15 deletions(-) diff --git a/src/nools/task-recurring.js b/src/nools/task-recurring.js index 7a35491f4..0b013d5b3 100644 --- a/src/nools/task-recurring.js +++ b/src/nools/task-recurring.js @@ -3,26 +3,36 @@ const { DateTime, Duration, Interval } = require('luxon'); const DEFAULT_PERIOD = 1; const DEFAULT_PERIOD_UNIT = 'day'; -function getulateRecurringEvents(emitterContext) { +/* +Interval Definitions: + +Timely - Interval in which task emissions are used (defined by cht-core) +Scheduled - Interval in which the user specified the task to recur +Task - Interval in which a task would be visible to the user if emitted +Emission - An emission is made if the task interval overlaps this interval +Iteration - Inverval over which task intervals are evaluated +*/ + +function getRecurringEvents(emitterContext) { const { name, events } = emitterContext.taskDefinition; const timelyInterval = getTimelyInterval(); - const recurringInterval = getRecurringInterval(events); - const criteriaInterval = getCriteriaInterval(recurringInterval, timelyInterval, events); + const scheduledInterval = getScheduledInterval(events); + const emissionInterval = getEmissionInterval(scheduledInterval, timelyInterval, events); // an invalid interval results if it ends before it starts - if (!criteriaInterval || !criteriaInterval.isValid) { + if (!emissionInterval || !emissionInterval.isValid) { return []; } const periodDuration = getPeriodAsDuration(events); - const iterationInterval = getIterationInterval(events, recurringInterval, criteriaInterval, periodDuration); + const iterationInterval = getIterationInterval(events, scheduledInterval, emissionInterval, periodDuration); let dueDateIterator = iterationInterval.start; const result = []; while (dueDateIterator < iterationInterval.end) { const taskInterval = getTaskInterval(dueDateIterator, events); - if (criteriaInterval.overlaps(taskInterval)) { + if (emissionInterval.overlaps(taskInterval)) { const uuidPrefix = emitterContext.r ? emitterContext.r._id : emitterContext.c.contact && emitterContext.c.contact._id; result.push({ _id: `${uuidPrefix}~recurring~${dueDateIterator.toISODate()}~${name}`, @@ -46,7 +56,7 @@ function getTimelyInterval() { ); } -function getRecurringInterval(events) { +function getScheduledInterval(events) { const START_OF_TIME = DateTime.fromISO('1000-01-01'); const END_OF_TIME = DateTime.fromISO('3000-01-01'); const start = userInputToDateTime(events.recurringStartDate) || START_OF_TIME; @@ -54,12 +64,12 @@ function getRecurringInterval(events) { return Interval.fromDateTimes(start.startOf('day'), end.endOf('day')); } -function getCriteriaInterval(recurringInterval, timelyInterval, events) { +function getEmissionInterval(scheduledInterval, timelyInterval, events) { const expandedTimelyInterval = Interval.fromDateTimes( timelyInterval.start.plus({ days: -events.end || 0 }), timelyInterval.end.plus({ days: events.start || 0 }) ); - return recurringInterval.intersection(expandedTimelyInterval); + return scheduledInterval.intersection(expandedTimelyInterval); } function getPeriodAsDuration(events) { @@ -85,7 +95,7 @@ function getPeriodAsDuration(events) { return periodDuration; } -function getIterationInterval(events, recurringInterval, criteriaInterval, period) { +function getIterationInterval(events, scheduledInterval, emissionInterval, period) { const advanceDateTimeUsingPeriod = (from, to, roundToFloor = true) => { const aggregator = roundToFloor ? Math.floor : Math.ceil; const periodUnit = Object.keys(period.toObject())[0]; @@ -95,12 +105,12 @@ function getIterationInterval(events, recurringInterval, criteriaInterval, perio }; // the visibility interval (events.start) must impact the iteration window, but periodic information should be maintained - const startWithoutPeriod = criteriaInterval.start.plus({ days: -events.start || 0 }); - const endWithoutPeriod = criteriaInterval.end.plus({ days: events.end || 0 }); + const startWithoutPeriod = emissionInterval.start.plus({ days: -events.start || 0 }); + const endWithoutPeriod = emissionInterval.end.plus({ days: events.end || 0 }); return Interval.fromDateTimes( - advanceDateTimeUsingPeriod(recurringInterval.start, startWithoutPeriod), - advanceDateTimeUsingPeriod(recurringInterval.end, endWithoutPeriod, false) + advanceDateTimeUsingPeriod(scheduledInterval.start, startWithoutPeriod), + advanceDateTimeUsingPeriod(scheduledInterval.end, endWithoutPeriod, false) ); } @@ -133,4 +143,4 @@ function userInputToDateTime(input) { return result; } -module.exports = getulateRecurringEvents; +module.exports = getRecurringEvents;