diff --git a/www/__mocks__/cordovaMocks.ts b/www/__mocks__/cordovaMocks.ts index 1f563c7e7..e5a5e2235 100644 --- a/www/__mocks__/cordovaMocks.ts +++ b/www/__mocks__/cordovaMocks.ts @@ -8,6 +8,16 @@ export const mockCordova = () => { window['cordova'].plugins ||= {}; }; +export const mockReminders = () => { + window['cordova'] ||= {}; + window['cordova'].plugins ||= {}; + window['cordova'].plugins.notification ||= {}; + window['cordova'].plugins.notification.local ||= {}; + window['cordova'].plugins.notification.local.getScheduled ||= () => []; + window['cordova'].plugins.notification.local.cancelAll ||= () => {}; + window['cordova'].plugins.notification.local.schedule ||= () => {}; +}; + export const mockDevice = () => { window['device'] ||= {}; window['device'].platform ||= 'ios'; diff --git a/www/__mocks__/globalMocks.ts b/www/__mocks__/globalMocks.ts index 62ea1b935..a62c473b7 100644 --- a/www/__mocks__/globalMocks.ts +++ b/www/__mocks__/globalMocks.ts @@ -1,5 +1,11 @@ export const mockLogger = () => { window['Logger'] = { log: console.log }; + window.alert = (msg) => { + console.log(msg); + }; + console.error = (msg) => { + console.log(msg); + }; }; let alerts = []; diff --git a/www/__tests__/notifScheduler.test.ts b/www/__tests__/notifScheduler.test.ts new file mode 100644 index 000000000..8b74fe7ba --- /dev/null +++ b/www/__tests__/notifScheduler.test.ts @@ -0,0 +1,471 @@ +import { mockReminders } from '../__mocks__/cordovaMocks'; +import { mockLogger } from '../__mocks__/globalMocks'; +import i18next from 'i18next'; +import { logDebug } from '../js/plugin/logger'; +import { DateTime } from 'luxon'; +import { getUser, updateUser } from '../js/services/commHelper'; +import { addStatReading } from '../js/plugin/clientStats'; +import { + getScheduledNotifs, + updateScheduledNotifs, + getReminderPrefs, + setReminderPrefs, +} from '../js/splash/notifScheduler'; + +const exampleReminderSchemes = { + weekly: { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tómese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicación y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '21:00', + }, + 'week-quarterly': { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tómese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicación y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '22:00', + }, + passive: { + title: { + en: 'Please take a moment to label your trips', + es: 'Por favor, tómese un momento para etiquetar sus viajes', + }, + text: { + en: 'Click to open the app and view unlabeled trips', + es: 'Haga clic para abrir la aplicación y ver los viajes sin etiquetar', + }, + schedule: [ + { start: 0, end: 1, intervalInDays: 1 }, + { start: 3, end: 5, intervalInDays: 2 }, + ], + defaultTime: '23:00', + }, +}; + +mockLogger(); +mockReminders(); + +jest.mock('i18next', () => ({ + resolvedLanguage: 'en', +})); + +jest.mock('../js/services/commHelper', () => ({ + getUser: jest.fn(), + updateUser: jest.fn(), +})); +const mockGetUser = getUser as jest.Mock; +const mockUpdateUser = updateUser as jest.Mock; + +jest.mock('../js/plugin/clientStats', () => ({ + ...jest.requireActual('../js/plugin/clientStats'), + addStatReading: jest.fn(), +})); + +jest.mock('../js/plugin/logger', () => ({ + ...jest.requireActual('../js/plugin/logger'), + logDebug: jest.fn(), +})); + +jest.mock('../js/splash/notifScheduler', () => ({ + ...jest.requireActual('../js/splash/notifScheduler'), + // for getScheduledNotifs + getNotifs: jest.fn(), + // for updateScheduledNotifs + calcNotifTimes: jest.fn(), + removeEmptyObjects: jest.fn(), + areAlreadyScheduled: jest.fn(), + scheduleNotifs: jest.fn(), +})); + +jest.mock('../js/plugin/clientStats'); + +describe('getScheduledNotifs', () => { + it('should resolve with notifications while not actively scheduling', async () => { + // getScheduledNotifs arguments + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin + const mockNotifs = [{ trigger: { at: DateTime.now().toJSDate() } }]; + // create the expected result + const expectedResult = [ + { + key: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('t'), + }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the function + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should resolve with notifications if actively scheduling', async () => { + // getScheduledNotifs arguments + const isScheduling = true; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin + const mockNotifs = [{ trigger: { at: DateTime.now().toJSDate() } }]; + // create the expected result + const expectedResult = [ + { + key: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[0].trigger.at).toFormat('t'), + }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton + const scheduledNotifs = await getScheduledNotifs(isScheduling, scheduledPromise); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should handle case where no notifications are present', async () => { + // getScheduledNotifs arguments + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin + const mockNotifs = []; + // create the expected result + const expectedResult = []; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); + + it('should handle the case where greater than 5 notifications are present', async () => { + // getScheduledNotifs arguments + const isScheduling = false; + const scheduledPromise = Promise.resolve(); + // create the mock notifs from cordova plugin (greater than 5 notifications) + const mockNotifs = [ + { trigger: { at: DateTime.now().toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 1 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 2 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 3 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 4 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 5 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 6 }).toJSDate() } }, + { trigger: { at: DateTime.now().plus({ weeks: 7 }).toJSDate() } }, + ]; + // create the expected result (only the first 5 notifications) + const expectedResult = [ + { + key: DateTime.fromJSDate(mockNotifs[0].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[0].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[1].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[1].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[2].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[2].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[3].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[3].trigger.at as Date).toFormat('t'), + }, + { + key: DateTime.fromJSDate(mockNotifs[4].trigger.at as Date).toFormat('DDD'), + val: DateTime.fromJSDate(mockNotifs[4].trigger.at as Date).toFormat('t'), + }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the funciton + const scheduledNotifs = await getScheduledNotifs(isScheduling, Promise.resolve()); + + expect(scheduledNotifs).toEqual(expectedResult); + }); +}); + +describe('updateScheduledNotifs', () => { + afterEach(() => { + jest.restoreAllMocks(); // Restore mocked functions after each test + }); + + beforeEach(() => { + // mock the getUser function + mockGetUser.mockImplementation(() => + Promise.resolve({ + // These values are **important**... + // reminder_assignment: must match a key from the reminder scheme above, + // reminder_join_date: must match the first day of the mocked notifs below in the tests, + // reminder_time_of_day: must match the defaultTime from the chosen reminder_assignment in the reminder scheme above + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ); + }); + + it('should resolve after scheduling notifications', async () => { + // updateScheduleNotifs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create an empty array of mock notifs from cordova plugin + let mockNotifs = []; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + jest + .spyOn(window['cordova'].plugins.notification.local, 'cancelAll') + .mockImplementation((callback) => { + mockNotifs = []; + callback(); + }); + jest + .spyOn(window['cordova'].plugins.notification.local, 'schedule') + .mockImplementation((arg, callback) => { + arg.forEach((notif) => { + mockNotifs.push(notif); + }); + console.log('called mockNotifs.concat(arg)', mockNotifs); + callback(arg); + }); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + const scheduledNotifs = await getScheduledNotifs(isScheduling, scheduledPromise); + + expect(setIsScheduling).toHaveBeenCalledWith(true); + expect(logDebug).toHaveBeenCalledWith('After cancelling, there are no scheduled notifications'); + expect(logDebug).toHaveBeenCalledWith( + 'After scheduling, there are 4 scheduled notifications at 21:00 first is November 19, 2023 at 9:00 PM', + ); + expect(setIsScheduling).toHaveBeenCalledWith(false); + expect(scheduledNotifs).toHaveLength(4); + }); + + it('should resolve without scheduling if notifications are already scheduled', async () => { + // updateScheduleNotifs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create the mock notifs from cordova plugin (must match the notifs that will generate from the reminder scheme above... + // in this case: exampleReminderSchemes.weekly, because getUser is mocked to return reminder_assignment: 'weekly') + const mockNotifs = [ + { trigger: { at: DateTime.fromFormat('2023-11-14 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + { trigger: { at: DateTime.fromFormat('2023-11-15 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + { trigger: { at: DateTime.fromFormat('2023-11-17 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + { trigger: { at: DateTime.fromFormat('2023-11-19 21:00', 'yyyy-MM-dd HH:mm').toJSDate() } }, + ]; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith('Already scheduled, not scheduling again'); + }); + + it('should wait for the previous scheduling to finish if isScheduling is true', async () => { + // updateScheduleNotifs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create an empty array of mock notifs from cordova plugin + const mockNotifs = []; + + // mock the cordova plugin + jest + .spyOn(window['cordova'].plugins.notification.local, 'getScheduled') + .mockImplementation((callback) => callback(mockNotifs)); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith( + 'ERROR: Already scheduling notifications, not scheduling again', + ); + }); + + it('should log an error message if the reminder scheme is missing', async () => { + // updateScheduleNotifs arguments + let reminderSchemes: any = exampleReminderSchemes; + delete reminderSchemes.weekly; // delete the weekly reminder scheme, to create a missing reminder scheme error + let isScheduling: boolean = false; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // call the function + await updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith('Error: Reminder scheme not found'); + }); +}); + +describe('getReminderPrefs', () => { + afterEach(() => { + jest.restoreAllMocks(); // Restore mocked functions after each test + }); + + it('should resolve with newly initialilzed reminder prefs when user does not exist', async () => { + // getReminderPrefs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create the expected result + const expectedResult = { + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }; + + // mock the getUser function to return a user that does not exist: + // first, as undefined to get the not-yet-created user behavior, + // then, as a user with reminder prefs to prevent infinite looping (since updateUser does not update the user) + mockGetUser + .mockImplementation(() => + Promise.resolve({ + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ) + .mockImplementationOnce(() => + Promise.resolve({ + reminder_assignment: undefined, + reminder_join_date: undefined, + reminder_time_of_day: undefined, + }), + ); + // mock addStatReading for the setReminderPrefs portion of getReminderPrefs + // typescript causes us to need to use "... as jest.Mock" to mock funcitons that are imported from other files + (addStatReading as jest.Mock).mockImplementation(() => Promise.resolve()); + + // call the function + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = + await getReminderPrefs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(logDebug).toHaveBeenCalledWith('User just joined, Initializing reminder prefs'); + expect(logDebug).toHaveBeenCalledWith('Added reminder prefs to client stats'); + }); + + it('should resolve with reminder prefs when user exists', async () => { + // getReminderPrefs arguments + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + // create the expected result + const expectedResult = { + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }; + + // mock the getUser function + mockGetUser.mockImplementation(() => + Promise.resolve({ + // These values are **important**... + // reminder_assignment: must match a key from the reminder scheme above, + // reminder_join_date: must match the first day of the mocked notifs below in the tests, + // reminder_time_of_day: must match the defaultTime from the chosen reminder_assignment in the reminder scheme above + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ); + + // call the function + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = + await getReminderPrefs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise); + + expect(reminder_assignment).toEqual(expectedResult.reminder_assignment); + expect(reminder_join_date).toEqual(expectedResult.reminder_join_date); + expect(reminder_time_of_day).toEqual(expectedResult.reminder_time_of_day); + }); +}); + +describe('setReminderPrefs', () => { + afterEach(() => { + jest.restoreAllMocks(); // Restore mocked functions after each test + }); + + beforeEach(() => { + // mock the getUser function + mockGetUser.mockImplementation(() => + Promise.resolve({ + // These values are **important**... + // reminder_assignment: must match a key from the reminder scheme above, + // reminder_join_date: must match the first day of the mocked notifs below in the tests, + // reminder_time_of_day: must match the defaultTime from the chosen reminder_assignment in the reminder scheme above + reminder_assignment: 'weekly', + reminder_join_date: '2023-11-14', + reminder_time_of_day: '21:00', + }), + ); + }); + + it('should resolve with promise that calls updateScheduledNotifs', async () => { + // setReminderPrefs arguments + const newPrefs: any = { + reminder_time_of_day: '21:00', + }; + const reminderSchemes: any = exampleReminderSchemes; + let isScheduling: boolean = true; + const setIsScheduling: Function = jest.fn((val: boolean) => (isScheduling = val)); + const scheduledPromise: Promise = Promise.resolve(); + + // mock the updateUser function + mockUpdateUser.mockImplementation(() => Promise.resolve()); + + // call the function + setReminderPrefs( + newPrefs, + reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ).then(() => { + // in the implementation in ProfileSettings.jsx, + // refresNotificationSettings(); + // would be called next + }); + + expect(logDebug).toBeCalledWith('Added reminder prefs to client stats'); + }); +}); diff --git a/www/index.js b/www/index.js index 997141073..e087925b1 100644 --- a/www/index.js +++ b/www/index.js @@ -6,7 +6,6 @@ import 'leaflet/dist/leaflet.css'; import './js/ngApp.js'; import './js/splash/referral.js'; import './js/splash/localnotify.js'; -import './js/splash/notifScheduler.js'; import './js/controllers.js'; import './js/services.js'; import './js/i18n-utils.js'; diff --git a/www/js/control/ProfileSettings.jsx b/www/js/control/ProfileSettings.jsx index ae42904d8..36102a252 100644 --- a/www/js/control/ProfileSettings.jsx +++ b/www/js/control/ProfileSettings.jsx @@ -32,7 +32,14 @@ import { shareQR } from '../components/QrCode'; import { storageClear } from '../plugin/storage'; import { getAppVersion } from '../plugin/clientStats'; import { getConsentDocument } from '../splash/startprefs'; -import { logDebug } from '../plugin/logger'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; +import { + updateScheduledNotifs, + getScheduledNotifs, + getReminderPrefs, + setReminderPrefs, +} from '../splash/notifScheduler'; +import { DateTime } from 'luxon'; import { fetchOPCode, getSettings } from '../services/controlHelper'; //any pure functions can go outside @@ -43,9 +50,6 @@ const ProfileSettings = () => { const { colors } = useTheme(); const { setPermissionsPopupVis } = useContext(AppContext); - //angular services needed - const NotificationScheduler = getAngularService('NotificationScheduler'); - //functions that come directly from an Angular service const editCollectionConfig = () => setEditCollectionVis(true); const editSyncConfig = () => setEditSync(true); @@ -89,6 +93,10 @@ const ProfileSettings = () => { { text: 'Remote push', transition: 'RECEIVED_SILENT_PUSH' }, ]; + // used for scheduling notifs + let scheduledPromise = new Promise((rs) => rs()); + const [isScheduling, setIsScheduling] = useState(false); + useEffect(() => { //added appConfig.name needed to be defined because appConfig was defined but empty if (appConfig && appConfig.name) { @@ -135,6 +143,22 @@ const ProfileSettings = () => { tempUiConfig.opcode.autogen = tempUiConfig?.intro.program_or_study == 'study'; } + if (tempUiConfig.reminderSchemes) { + // Update the scheduled notifs + updateScheduledNotifs( + tempUiConfig.reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ) + .then(() => { + logDebug('updated scheduled notifs'); + }) + .catch((err) => { + displayErrorMsg('Error while updating scheduled notifs', err); + }); + } + // setTemplateText(tempUiConfig.intro.translated_text); // console.log("translated text is??", templateText); setUiConfig(tempUiConfig); @@ -174,27 +198,42 @@ const ProfileSettings = () => { }, [editCollectionVis]); async function refreshNotificationSettings() { - console.debug( - 'about to refreshNotificationSettings, notificationSettings = ', - notificationSettings, + logDebug( + 'about to refreshNotificationSettings, notificationSettings = ' + + JSON.stringify(notificationSettings), ); const newNotificationSettings = {}; if (uiConfig?.reminderSchemes) { - const prefs = await NotificationScheduler.getReminderPrefs(); - const m = moment(prefs.reminder_time_of_day, 'HH:mm'); - newNotificationSettings.prefReminderTimeVal = m.toDate(); - const n = moment(newNotificationSettings.prefReminderTimeVal); - newNotificationSettings.prefReminderTime = n.format('LT'); + let promiseList = []; + promiseList.push( + getReminderPrefs(uiConfig.reminderSchemes, isScheduling, setIsScheduling, scheduledPromise), + ); + promiseList.push(getScheduledNotifs(isScheduling, scheduledPromise)); + let resultList = await Promise.all(promiseList); + const prefs = resultList[0]; + const scheduledNotifs = resultList[1]; + logDebug( + 'prefs and scheduled notifs\n' + + JSON.stringify(prefs) + + '\n-\n' + + JSON.stringify(scheduledNotifs), + ); + + const m = DateTime.fromFormat(prefs.reminder_time_of_day, 'HH:mm'); + newNotificationSettings.prefReminderTimeVal = m.toJSDate(); + newNotificationSettings.prefReminderTime = m.toFormat('t'); newNotificationSettings.prefReminderTimeOnLoad = prefs.reminder_time_of_day; - newNotificationSettings.scheduledNotifs = await NotificationScheduler.getScheduledNotifs(); + newNotificationSettings.scheduledNotifs = scheduledNotifs; + updatePrefReminderTime(false); } - console.log( - 'notification settings before and after', - notificationSettings, - newNotificationSettings, + logDebug( + 'notification settings before and after\n' + + JSON.stringify(notificationSettings) + + '\n-\n' + + JSON.stringify(newNotificationSettings), ); setNotificationSettings(newNotificationSettings); } @@ -256,13 +295,17 @@ const ProfileSettings = () => { async function updatePrefReminderTime(storeNewVal = true, newTime) { console.log(newTime); if (storeNewVal) { - const m = moment(newTime); + const m = DateTime.fromISO(newTime); // store in HH:mm - NotificationScheduler.setReminderPrefs({ reminder_time_of_day: m.format('HH:mm') }).then( - () => { - refreshNotificationSettings(); - }, - ); + setReminderPrefs( + { reminder_time_of_day: m.toFormat('HH:mm') }, + uiConfig.reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ).then(() => { + refreshNotificationSettings(); + }); } } diff --git a/www/js/main.js b/www/js/main.js index 2c789891a..a343f1d7a 100644 --- a/www/js/main.js +++ b/www/js/main.js @@ -3,12 +3,7 @@ import angular from 'angular'; angular - .module('emission.main', [ - 'emission.main.diary', - 'emission.i18n.utils', - 'emission.splash.notifscheduler', - 'emission.services', - ]) + .module('emission.main', ['emission.main.diary', 'emission.i18n.utils', 'emission.services']) .config(function ($stateProvider) { $stateProvider.state('root.main', { diff --git a/www/js/splash/notifScheduler.js b/www/js/splash/notifScheduler.js deleted file mode 100644 index d44059848..000000000 --- a/www/js/splash/notifScheduler.js +++ /dev/null @@ -1,265 +0,0 @@ -'use strict'; - -import angular from 'angular'; -import { getConfig } from '../config/dynamicConfig'; -import { addStatReading, statKeys } from '../plugin/clientStats'; -import { getUser, updateUser } from '../services/commHelper'; - -angular - .module('emission.splash.notifscheduler', ['emission.services', 'emission.plugin.logger']) - - .factory('NotificationScheduler', function ($http, $window, $ionicPlatform, Logger) { - const scheduler = {}; - let _config; - let scheduledPromise = new Promise((rs) => rs()); - let isScheduling = false; - - // like python range() - function range(start, stop, step) { - let a = [start], - b = start; - while (b < stop) a.push((b += step || 1)); - return a; - } - - // returns an array of moment objects, for all times that notifications should be sent - const calcNotifTimes = (scheme, dayZeroDate, timeOfDay) => { - const notifTimes = []; - for (const s of scheme.schedule) { - // the days to send notifications, as integers, relative to day zero - const notifDays = range(s.start, s.end, s.intervalInDays); - for (const d of notifDays) { - const date = moment(dayZeroDate).add(d, 'days').format('YYYY-MM-DD'); - const notifTime = moment(date + ' ' + timeOfDay, 'YYYY-MM-DD HH:mm'); - notifTimes.push(notifTime); - } - } - return notifTimes; - }; - - // returns true if all expected times are already scheduled - const areAlreadyScheduled = (notifs, expectedTimes) => { - for (const t of expectedTimes) { - if (!notifs.some((n) => moment(n.at).isSame(t))) { - return false; - } - } - return true; - }; - - /* remove notif actions as they do not work, can restore post routing migration */ - // const setUpActions = () => { - // const action = { - // id: 'action', - // title: 'Change Time', - // launch: true - // }; - // return new Promise((rs) => { - // cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); - // }); - // } - - function debugGetScheduled(prefix) { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length) return Logger.log(`${prefix}, there are no scheduled notifications`); - const time = moment(notifs?.[0].trigger.at).format('HH:mm'); - //was in plugin, changed to scheduler - scheduler.scheduledNotifs = notifs.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time, - }; - }); - //have the list of scheduled show up in this log - Logger.log( - `${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduler.scheduledNotifs[0].key} at ${scheduler.scheduledNotifs[0].val}`, - ); - }); - } - - //new method to fetch notifications - scheduler.getScheduledNotifs = function () { - return new Promise((resolve, reject) => { - /* if the notifications are still in active scheduling it causes problems - anywhere from 0-n of the scheduled notifs are displayed - if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors - */ - if (isScheduling) { - console.log( - 'requesting fetch while still actively scheduling, waiting on scheduledPromise', - ); - scheduledPromise.then(() => { - getNotifs().then((notifs) => { - console.log('done scheduling notifs', notifs); - resolve(notifs); - }); - }); - } else { - getNotifs().then((notifs) => { - resolve(notifs); - }); - } - }); - }; - - //get scheduled notifications from cordova plugin and format them - const getNotifs = function () { - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (!notifs?.length) { - console.log('there are no notifications'); - resolve([]); //if none, return empty array - } - - const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing - let scheduledNotifs = []; - scheduledNotifs = notifSubset.map((n) => { - const time = moment(n.trigger.at).format('LT'); - const date = moment(n.trigger.at).format('LL'); - return { - key: date, - val: time, - }; - }); - resolve(scheduledNotifs); - }); - }); - }; - - // schedules the notifications using the cordova plugin - const scheduleNotifs = (scheme, notifTimes) => { - return new Promise((rs) => { - isScheduling = true; - const localeCode = i18next.resolvedLanguage; - const nots = notifTimes.map((n) => { - const nDate = n.toDate(); - const seconds = nDate.getTime() / 1000; - return { - id: seconds, - title: scheme.title[localeCode], - text: scheme.text[localeCode], - trigger: { at: nDate }, - // actions: 'reminder-actions', - // data: { - // action: { - // redirectTo: 'root.main.control', - // redirectParams: { - // openTimeOfDayPicker: true - // } - // } - // } - }; - }); - cordova.plugins.notification.local.cancelAll(() => { - debugGetScheduled('After cancelling'); - cordova.plugins.notification.local.schedule(nots, () => { - debugGetScheduled('After scheduling'); - isScheduling = false; - rs(); //scheduling promise resolved here - }); - }); - }); - }; - - // determines when notifications are needed, and schedules them if not already scheduled - const update = async () => { - const { reminder_assignment, reminder_join_date, reminder_time_of_day } = - await scheduler.getReminderPrefs(); - const scheme = _config.reminderSchemes[reminder_assignment]; - const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); - - return new Promise((resolve, reject) => { - cordova.plugins.notification.local.getScheduled((notifs) => { - if (areAlreadyScheduled(notifs, notifTimes)) { - Logger.log('Already scheduled, not scheduling again'); - } else { - // to ensure we don't overlap with the last scheduling() request, - // we'll wait for the previous one to finish before scheduling again - scheduledPromise.then(() => { - if (isScheduling) { - console.log('ERROR: Already scheduling notifications, not scheduling again'); - } else { - scheduledPromise = scheduleNotifs(scheme, notifTimes); - //enforcing end of scheduling to conisder update through - scheduledPromise.then(() => { - resolve(); - }); - } - }); - } - }); - }); - }; - - /* Randomly assign a scheme, set the join date to today, - and use the default time of day from config (or noon if not specified) - This is only called once when the user first joins the study - */ - const initReminderPrefs = () => { - // randomly assign from the schemes listed in config - const schemes = Object.keys(_config.reminderSchemes); - const randAssignment = schemes[Math.floor(Math.random() * schemes.length)]; - const todayDate = moment().format('YYYY-MM-DD'); - const defaultTime = _config.reminderSchemes[randAssignment]?.defaultTime || '12:00'; - return { - reminder_assignment: randAssignment, - reminder_join_date: todayDate, - reminder_time_of_day: defaultTime, - }; - }; - - /* EXAMPLE VALUES - present in user profile object - reminder_assignment: 'passive', - reminder_join_date: '2023-05-09', - reminder_time_of_day: '21:00', - */ - - scheduler.getReminderPrefs = async () => { - const user = await getUser(); - if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { - return user; - } - // if no prefs, user just joined, so initialize them - const initPrefs = initReminderPrefs(); - await scheduler.setReminderPrefs(initPrefs); - return { ...user, ...initPrefs }; // user profile + the new prefs - }; - - scheduler.setReminderPrefs = async (newPrefs) => { - await updateUser(newPrefs); - const updatePromise = new Promise((resolve, reject) => { - //enforcing update before moving on - update().then(() => { - resolve(); - }); - }); - - // record the new prefs in client stats - scheduler.getReminderPrefs().then((prefs) => { - // extract only the relevant fields from the prefs, - // and add as a reading to client stats - const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; - addStatReading(statKeys.REMINDER_PREFS, { - reminder_assignment, - reminder_join_date, - reminder_time_of_day, - }).then(Logger.log('Added reminder prefs to client stats')); - }); - - return updatePromise; - }; - - $ionicPlatform.ready().then(async () => { - _config = await getConfig(); - if (!_config.reminderSchemes) { - Logger.log('No reminder schemes found in config, not scheduling notifications'); - return; - } - //setUpActions(); - update(); - }); - - return scheduler; - }); diff --git a/www/js/splash/notifScheduler.ts b/www/js/splash/notifScheduler.ts new file mode 100644 index 000000000..15adb2521 --- /dev/null +++ b/www/js/splash/notifScheduler.ts @@ -0,0 +1,294 @@ +import { addStatReading, statKeys } from '../plugin/clientStats'; +import { getUser, updateUser } from '../services/commHelper'; +import { displayErrorMsg, logDebug } from '../plugin/logger'; +import { DateTime } from 'luxon'; +import i18next from 'i18next'; +import { ReminderSchemeConfig } from '../types/appConfigTypes'; + +// like python range() +function range(start, stop, step) { + let a = [start], + b = start; + while (b < stop) a.push((b += step || 1)); + return a; +} + +// returns an array of DateTime objects, for all times that notifications should be sent +const calcNotifTimes = (scheme, dayZeroDate, timeOfDay): DateTime[] => { + const notifTimes: DateTime[] = []; + for (const s of scheme.schedule) { + // the days to send notifications, as integers, relative to day zero + const notifDays = range(s.start, s.end, s.intervalInDays); + for (const d of notifDays) { + const date = DateTime.fromFormat(dayZeroDate, 'yyyy-MM-dd') + .plus({ days: d }) + .toFormat('yyyy-MM-dd'); + const notifTime = DateTime.fromFormat(date + ' ' + timeOfDay, 'yyyy-MM-dd HH:mm'); + notifTimes.push(notifTime); + } + } + return notifTimes; +}; + +// returns true if all expected times are already scheduled +const areAlreadyScheduled = (notifs: any[], expectedTimes: DateTime[]) => { + for (const t of expectedTimes) { + if (!notifs.some((n) => DateTime.fromJSDate(n.trigger.at).equals(t))) { + return false; + } + } + return true; +}; + +/* remove notif actions as they do not work, can restore post routing migration */ +// const setUpActions = () => { +// const action = { +// id: 'action', +// title: 'Change Time', +// launch: true +// }; +// return new Promise((rs) => { +// cordova.plugins.notification.local.addActions('reminder-actions', [action], rs); +// }); +// } +function debugGetScheduled(prefix) { + window['cordova'].plugins.notification.local.getScheduled((notifs) => { + if (!notifs?.length) return logDebug(`${prefix}, there are no scheduled notifications`); + const time = DateTime.fromJSDate(notifs[0].trigger.at).toFormat('HH:mm'); + //was in plugin, changed to scheduler + let scheduledNotifs = []; + scheduledNotifs = notifs.map((n) => { + const date = DateTime.fromJSDate(n.trigger.at).toFormat('DDD'); + const time = DateTime.fromJSDate(n.trigger.at).toFormat('t'); + return { + key: date, + val: time, + }; + }); + //have the list of scheduled show up in this log + logDebug( + `${prefix}, there are ${notifs.length} scheduled notifications at ${time} first is ${scheduledNotifs[0].key} at ${scheduledNotifs[0].val}`, + ); + }); +} + +//new method to fetch notifications +export const getScheduledNotifs = function (isScheduling: boolean, scheduledPromise: Promise) { + return new Promise((resolve, reject) => { + /* if the notifications are still in active scheduling it causes problems + anywhere from 0-n of the scheduled notifs are displayed + if actively scheduling, wait for the scheduledPromise to resolve before fetching prevents such errors + */ + if (isScheduling) { + logDebug('requesting fetch while still actively scheduling, waiting on scheduledPromise'); + scheduledPromise.then(() => { + getNotifs().then((notifs: object[]) => { + resolve(notifs); + }); + }); + } else { + getNotifs().then((notifs: object[]) => { + resolve(notifs); + }); + } + }); +}; + +//get scheduled notifications from cordova plugin and format them +const getNotifs = function () { + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs: any[]) => { + if (!notifs?.length) { + logDebug('there are no notifications'); + resolve([]); //if none, return empty array + } else { + // some empty objects slip through, remove them from notifs + notifs = removeEmptyObjects(notifs); + } + + const notifSubset = notifs.slice(0, 5); //prevent near-infinite listing + let scheduledNotifs = []; + scheduledNotifs = notifSubset.map((n) => { + const time: string = DateTime.fromJSDate(n.trigger.at).toFormat('t'); + const date: string = DateTime.fromJSDate(n.trigger.at).toFormat('DDD'); + return { + key: date, + val: time, + }; + }); + resolve(scheduledNotifs); + }); + }); +}; + +// schedules the notifications using the cordova plugin +const scheduleNotifs = (scheme, notifTimes: DateTime[], setIsScheduling: Function) => { + return new Promise((rs) => { + setIsScheduling(true); + const localeCode = i18next.resolvedLanguage; + const nots = notifTimes.map((n) => { + const nDate = n.toJSDate(); + const seconds = nDate.getTime() / 1000; // the id must be in seconds, otherwise the sorting won't work + return { + id: seconds, + title: scheme.title[localeCode], + text: scheme.text[localeCode], + trigger: { at: nDate }, + // actions: 'reminder-actions', + // data: { + // action: { + // redirectTo: 'root.main.control', + // redirectParams: { + // openTimeOfDayPicker: true + // } + // } + // } + }; + }); + nots.sort((a, b) => b.id - a.id); // sort notifications by id (time) + window['cordova'].plugins.notification.local.cancelAll(() => { + debugGetScheduled('After cancelling'); + window['cordova'].plugins.notification.local.schedule(nots, () => { + debugGetScheduled('After scheduling'); + setIsScheduling(false); + rs(); //scheduling promise resolved here + }); + }); + }); +}; + +const removeEmptyObjects = (list: any[]): any[] => { + return list.filter((n) => Object.keys(n).length !== 0); +}; + +// determines when notifications are needed, and schedules them if not already scheduled +export const updateScheduledNotifs = async ( + reminderSchemes: ReminderSchemeConfig, + isScheduling: boolean, + setIsScheduling: Function, + scheduledPromise: Promise, +): Promise => { + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = await getReminderPrefs( + reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ); + const scheme = reminderSchemes[reminder_assignment]; + if (scheme === undefined) { + logDebug('Error: Reminder scheme not found'); + return; + } + const notifTimes = calcNotifTimes(scheme, reminder_join_date, reminder_time_of_day); + return new Promise((resolve, reject) => { + window['cordova'].plugins.notification.local.getScheduled((notifs: any[]) => { + // some empty objects slip through, remove them from notifs + notifs = removeEmptyObjects(notifs); + if (areAlreadyScheduled(notifs, notifTimes)) { + logDebug('Already scheduled, not scheduling again'); + resolve(); + } else { + // to ensure we don't overlap with the last scheduling() request, + // we'll wait for the previous one to finish before scheduling again + scheduledPromise.then(() => { + if (isScheduling) { + logDebug('ERROR: Already scheduling notifications, not scheduling again'); + resolve(); + } else { + scheduledPromise = scheduleNotifs(scheme, notifTimes, setIsScheduling); + //enforcing end of scheduling to conisder update through + scheduledPromise.then(() => { + resolve(); + }); + } + }); + } + }); + }); +}; + +/* Randomly assign a scheme, set the join date to today, + and use the default time of day from config (or noon if not specified) + This is only called once when the user first joins the study +*/ +const initReminderPrefs = (reminderSchemes: object): object => { + // randomly assign from the schemes listed in config + const schemes = Object.keys(reminderSchemes); + const randAssignment: string = schemes[Math.floor(Math.random() * schemes.length)]; + const todayDate: string = DateTime.local().toFormat('yyyy-MM-dd'); + const defaultTime: string = reminderSchemes[randAssignment]?.defaultTime || '12:00'; + return { + reminder_assignment: randAssignment, + reminder_join_date: todayDate, + reminder_time_of_day: defaultTime, + }; +}; + +/* EXAMPLE VALUES - present in user profile object + reminder_assignment: 'passive', + reminder_join_date: '2023-05-09', + reminder_time_of_day: '21:00', +*/ + +interface User { + reminder_assignment: string; + reminder_join_date: string; + reminder_time_of_day: string; +} + +export const getReminderPrefs = async ( + reminderSchemes: ReminderSchemeConfig, + isScheduling: boolean, + setIsScheduling: Function, + scheduledPromise: Promise, +): Promise => { + const userPromise = getUser(); + const user = (await userPromise) as User; + if (user?.reminder_assignment && user?.reminder_join_date && user?.reminder_time_of_day) { + logDebug('User already has reminder prefs, returning them: ' + JSON.stringify(user)); + return user; + } + // if no prefs, user just joined, so initialize them + logDebug('User just joined, Initializing reminder prefs'); + const initPrefs = initReminderPrefs(reminderSchemes); + logDebug('Initialized reminder prefs: ' + JSON.stringify(initPrefs)); + await setReminderPrefs( + initPrefs, + reminderSchemes, + isScheduling, + setIsScheduling, + scheduledPromise, + ); + return { ...user, ...initPrefs }; // user profile + the new prefs +}; +export const setReminderPrefs = async ( + newPrefs: object, + reminderSchemes: ReminderSchemeConfig, + isScheduling: boolean, + setIsScheduling: Function, + scheduledPromise: Promise, +): Promise => { + await updateUser(newPrefs); + const updatePromise = new Promise((resolve, reject) => { + //enforcing update before moving on + updateScheduledNotifs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise).then( + () => { + resolve(); + }, + ); + }); + // record the new prefs in client stats + getReminderPrefs(reminderSchemes, isScheduling, setIsScheduling, scheduledPromise).then( + (prefs) => { + // extract only the relevant fields from the prefs, + // and add as a reading to client stats + const { reminder_assignment, reminder_join_date, reminder_time_of_day } = prefs; + addStatReading(statKeys.REMINDER_PREFS, { + reminder_assignment, + reminder_join_date, + reminder_time_of_day, + }).then(logDebug('Added reminder prefs to client stats')); + }, + ); + return updatePromise; +}; diff --git a/www/js/types/appConfigTypes.ts b/www/js/types/appConfigTypes.ts index 1a2e50722..a737ae2f3 100644 --- a/www/js/types/appConfigTypes.ts +++ b/www/js/types/appConfigTypes.ts @@ -7,6 +7,7 @@ export type AppConfig = { 'trip-labels': 'MULTILABEL' | 'ENKETO'; surveys: EnketoSurveyConfig; }; + reminderSchemes?: ReminderSchemeConfig; [k: string]: any; // TODO fill in all the other fields }; @@ -25,3 +26,16 @@ export type EnketoSurveyConfig = { dataKey?: string; }; }; + +export type ReminderSchemeConfig = { + [schemeName: string]: { + title: { [lang: string]: string }; + message: { [lang: string]: string }; + schedule: { + start: number; + end: number; + intervalInDays: number; + }[]; + defaultTime: string; // format is HH:MM in 24 hour time + }; +};