From 1961cf5e1073088fb9e9f4930682640990299cf0 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 21 May 2023 12:13:39 -0700 Subject: [PATCH 01/11] Properly calculate next sync time for scheduled syncs. Allow editing and deleting of scheduled syncs. --- .../SyncSchedule/EditDeviceSyncSchedule.vue | 310 +++++++++++++----- .../SyncSchedule/ManageSyncSchedule.vue | 46 ++- .../__tests__/EditDeviceSyncSchedule.spec.js | 82 +++++ .../components/SyncSchedule/constants.js | 10 + 4 files changed, 330 insertions(+), 118 deletions(-) create mode 100644 packages/kolibri-common/components/SyncSchedule/__tests__/EditDeviceSyncSchedule.spec.js diff --git a/packages/kolibri-common/components/SyncSchedule/EditDeviceSyncSchedule.vue b/packages/kolibri-common/components/SyncSchedule/EditDeviceSyncSchedule.vue index 6c4363283d2..57f2836f6ff 100644 --- a/packages/kolibri-common/components/SyncSchedule/EditDeviceSyncSchedule.vue +++ b/packages/kolibri-common/components/SyncSchedule/EditDeviceSyncSchedule.vue @@ -28,6 +28,7 @@ - + {{ $tr('serverTime') }} - {{ now }} + {{ + $formatTime(now, { + year: "numeric", + month: "numeric", + day: "numeric", + hour: "numeric", + minute: "numeric", + second: "numeric", + }) + }}

- + {{ $tr('checkboxLabel') }}

@@ -134,10 +148,7 @@ :layout12="{ span: 12 }" >

{{ $tr('removeDeviceWarning') }}

-

- -

-

+

{{ $tr('deviceNotConnected') }}

@@ -152,10 +163,37 @@ import ImmersivePage from 'kolibri.coreVue.components.ImmersivePage'; import BottomAppBar from 'kolibri.coreVue.components.BottomAppBar'; - import { NetworkLocationResource, FacilityResource, TaskResource } from 'kolibri.resources'; + import { NetworkLocationResource, TaskResource } from 'kolibri.resources'; import { now } from 'kolibri.utils.serverClock'; import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings'; - import { TaskTypes } from 'kolibri.utils.syncTaskUtils'; + import { TaskStatuses, TaskTypes } from 'kolibri.utils.syncTaskUtils'; + import { oneHour, oneDay, oneWeek, twoWeeks, oneMonth } from './constants'; + + const today = new Date(); + const daysOfWeek = []; + const date = new Date( + today.getFullYear(), + today.getMonth(), + today.getDate() + (7 - today.getDay()) + ); + for (let i = 0; i < 7; i++) { + daysOfWeek.push({ value: i, date: new Date(date) }); + date.setDate(date.getDate() + 1); + } + + const endTime = new Date(); + endTime.setHours(24, 0, 0, 0); + const interval = 30; + + const times = []; + var i = 0; + const time = new Date(); + time.setHours(0, 0, 0, 0); + + while (time < endTime) { + times.push({ value: i++, time: new Date(time) }); + time.setMinutes(time.getMinutes() + interval); + } export default { name: 'EditDeviceSyncSchedule', @@ -185,16 +223,13 @@ data() { return { removeDeviceModal: false, - deviceName: null, - device: [], - now: now(), + retryFlag: false, + device: null, + now: null, selectedItem: {}, tasks: [], selectedDay: {}, selectedTime: {}, - removeBtn: false, - serverTime: null, - baseurl: null, }; }, computed: { @@ -217,55 +252,65 @@ }, selectArray() { return [ - { label: this.$tr('everyHour'), value: 3600 }, - { label: this.$tr('everyDay'), value: 86400 }, - { label: this.$tr('everyWeek'), value: 604800 }, - { label: this.$tr('everyTwoWeeks'), value: 1209600 }, - { label: this.$tr('everyMonth'), value: 2592000 }, + { label: this.$tr('everyHour'), value: oneHour }, + { label: this.$tr('everyDay'), value: oneDay }, + { label: this.$tr('everyWeek'), value: oneWeek }, + { label: this.$tr('everyTwoWeeks'), value: twoWeeks }, + { label: this.$tr('everyMonth'), value: oneMonth }, ]; }, getDays() { - const today = new Date(); - const daysOfWeek = []; - const date = new Date( - today.getFullYear(), - today.getMonth(), - today.getDate() + (7 - today.getDay()) - ); - for (let i = 0; i < 7; i++) { - daysOfWeek.push({ label: this.$formatDate(date, { weekday: 'long' }), value: i }); - date.setDate(date.getDate() + 1); - } - return daysOfWeek; + return daysOfWeek.map(day => { + return { + label: this.$formatDate(day.date, { weekday: 'long' }), + value: day.value, + }; + }); }, SyncTime() { - const endTime = new Date(); - endTime.setHours(24, 0, 0, 0); - const interval = 30; - - const times = []; - var i = 0; - const time = new Date(); - time.setHours(0, 0, 0, 0); - - while (time < endTime) { - times.push({ label: this.$formatTime(time), value: i++ }); - time.setMinutes(time.getMinutes() + interval); - } - return times; + return times.map(time => { + return { + label: this.$formatTime(time.time), + value: time.value, + hours: time.time.getHours(), + minutes: time.time.getMinutes(), + }; + }); + }, + deviceName() { + return this.device && this.device.nickname.length + ? this.device.nickname + : this.device.device_name; + }, + currentTask() { + return this.tasks && this.tasks.length ? this.tasks[0] : null; + }, + currentTaskRunning() { + return this.currentTask && this.currentTask.status === TaskStatuses.RUNNING; + }, + timeRequired() { + return this.selectedItem.value > oneHour; + }, + timeIsSet() { + return this.selectedTime && times[this.selectedTime.value]; + }, + dayRequired() { + return this.selectedItem.value > oneDay; + }, + dayIsSet() { + return this.selectedDay && daysOfWeek[this.selectedDay.value]; }, }, - beforeMount() { + created() { this.fetchDevice(); - }, - mounted() { - this.serverTime = setInterval(() => { + this.now = now(); + this.serverTimeInterval = setInterval(() => { this.now = now(); }, 10000); }, beforeDestroy() { - clearInterval(this.serverTime); + clearInterval(this.serverTimeInterval); }, methods: { closeModal() { @@ -273,57 +318,140 @@ }, handleDeleteDevice() { this.removeDeviceModal = false; - NetworkLocationResource.deleteModel({ id: this.deviceId }) + TaskResource.deleteModel({ id: this.currentTask.id }) .then(() => { this.showSnackbarNotification('deviceRemove'); - history.back(); + this.goBack(); }) .catch(() => { this.showSnackbarNotification('deviceNotRemove'); }); }, + computeNextSync() { + const date = new Date(this.now); + if (this.timeRequired) { + if (!this.timeIsSet) { + throw new ReferenceError('Time is not set and is required'); + } + const hours = this.selectedTime.hours; + const minutes = this.selectedTime.minutes; + if ( + hours < date.getHours() || + (hours === date.getHours() && minutes < date.getMinutes()) + ) { + date.setDate(date.getDate() + 1); + } + date.setHours(hours); + date.setMinutes(minutes); + } + if (this.dayRequired) { + if (!this.dayIsSet) { + throw new ReferenceError('Day is not set and is required'); + } + const diff = this.selectedDay.value - date.getDay(); + if (date.getDay() > this.selectedDay.value) { + date.setDate(date.getDate() + 7 - Math.abs(diff)); + } else if (date.getDay() < this.selectedDay.value) { + date.setDate(date.getDate() + Math.abs(diff)); + } + } + return date; + }, handleSaveSchedule() { - FacilityResource.fetchModel({ id: this.facilityId, force: true }).then(facility => { - this.facility = { ...facility }; - const date = new Date(this.serverTime); - const equeue_param = date.toISOString(); - TaskResource.startTask({ + const enqueue_param = this.computeNextSync().toISOString(); + const enqueue_args = { + enqueue_at: enqueue_param, + repeat_interval: this.selectedItem.value, + repeat: null, + retry_interval: this.retryFlag ? 60 * 5 : null, + }; + let promise; + if (this.currentTask) { + promise = TaskResource.saveModel({ + id: this.currentTask.id, + data: { enqueue_args }, + exists: true, + }); + } else { + promise = TaskResource.startTask({ type: TaskTypes.SYNCPEERFULL, - facility: this.facility.id, + facility: this.facilityId, device_id: this.deviceId, - baseurl: this.baseurl, - enqueue_args: { - enqueue_at: equeue_param, - repeat_interval: this.selectedItem.value, - repeat: 2, - }, + baseurl: this.device.base_url, + enqueue_args, + }); + } + promise + .then(() => { + this.goBack(); + this.showSnackbarNotification('syncAdded'); }) - .then(() => { - history.back(); - this.showSnackbarNotification('syncAdded'); - }) - .catch(() => { - this.createTaskFailedSnackbar(); - }); - }); + .catch(() => { + this.createTaskFailedSnackbar(); + if (this.currentTask) { + this.fetchSyncTasks(); + } + }); }, - cancelBtn() { + goBack() { this.$router.push(this.goBackRoute); }, + pollFetchSyncTasks() { + this.pollInterval = setInterval(() => { + this.fetchSyncTasks(); + }, 10000); + }, + fetchSyncTasks() { + TaskResource.list({ queue: 'facility_task' }).then(tasks => { + this.tasks = tasks.filter( + task => + (task.extra_metadata.device_id === this.device.id && + task.facility_id === this.facilityId && + task.type === TaskTypes.SYNCPEERFULL) || + task.type === TaskTypes.SYNCDATAPORTAL + ); + this.$nextTick(() => { + if (this.currentTask) { + const enqueueAt = new Date(Date.parse(this.currentTask.scheduled_datetime)); + const day = enqueueAt.getDay(); + const hours = enqueueAt.getHours(); + const minutes = enqueueAt.getMinutes(); + this.selectedItem = + this.selectArray.find(item => item.value === this.currentTask.repeat_interval) || + {}; + this.selectedDay = this.getDays.find(item => item.value === day) || {}; + for (const time of this.SyncTime) { + // Because there can be some drift in the task scheduling process, + // we round the 'scheduled' time to the nearest 30 minutes + if ( + time.minutes === 0 && + ((time.hours === hours && minutes < 15) || + (time.hours === hours + 1 && minutes >= 45)) + ) { + this.selectedTime = time; + break; + } + if (time.minutes === 30 && time.hours === hours && minutes >= 15 && minutes < 45) { + this.selectedTime = time; + break; + } + } + this.retryFlag = Boolean(this.currentTask.retry_interval); + if (this.currentTaskRunning) { + this.pollFetchSyncTasks(); + } else { + clearInterval(this.pollInterval); + this.pollInterval = null; + } + } + }); + }); + }, fetchDevice() { NetworkLocationResource.fetchModel({ id: this.deviceId }).then(device => { this.device = device; - this.baseurl = device.base_url; - TaskResource.list({ queue: 'facility_task' }).then(tasks => { - this.tasks = tasks.filter( - task => - task.extra_metadata.device_id === device.id && task.facility_id === this.facilityId - ); - if (this.tasks && this.tasks.length) { - this.removeBtn = true; - } - }); + this.fetchSyncTasks(); }); }, }, diff --git a/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue b/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue index 7b6973c3958..e58a17c397b 100644 --- a/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue +++ b/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue @@ -198,6 +198,7 @@ import commonCoreStrings from 'kolibri.coreVue.mixins.commonCoreStrings'; import commonSyncElements from 'kolibri.coreVue.mixins.commonSyncElements'; import { AddDeviceForm } from 'kolibri.coreVue.componentSets.sync'; + import { oneHour, oneDay, oneWeek, twoWeeks, oneMonth } from './constants'; export default { name: 'ManageSyncSchedule', @@ -283,39 +284,30 @@ } }, scheduleTime(time, timestamp) { - const schedule = this.getDays(timestamp); - if (time === 3600) { + timestamp = new Date(Date.parse(timestamp)); + if (time === oneHour) { return this.$tr('everyHour'); } - if (time === 86400) { - const everyDay = this.$tr('everyDay') + ',' + this.getTime(timestamp); - return everyDay; + const options = { + weekday: 'long', + hour: 'numeric', + minute: 'numeric', + }; + let frequencyString; + if (time === oneDay) { + frequencyString = this.$tr('everyDay'); + delete options.weekday; } - if (time === 604800) { - const everyWeek = this.$tr('everyWeek') + ',' + schedule; - return everyWeek; + if (time === oneWeek) { + frequencyString = this.$tr('everyWeek'); } - if (time === 1209600) { - const everyTwoWeeks = this.$tr('everyTwoWeeks') + ',' + schedule; - return everyTwoWeeks; + if (time === twoWeeks) { + frequencyString = this.$tr('everyTwoWeeks'); } - if (time === 2592000) { - const everyMonth = this.$tr('everyMonth') + ',' + schedule; - return everyMonth; + if (time === oneMonth) { + frequencyString = this.$tr('everyMonth'); } - }, - getDays(timestamp) { - const dateTimeString = timestamp; - const date = new Date(dateTimeString); - const day = date.toLocaleDateString('en-US', { weekday: 'long' }); - const time = date.toLocaleTimeString('en-US', { hc: 'h24' }); - return `${day},${time}`; - }, - getTime(timestamp) { - const dateTimeString = timestamp; - const date = new Date(dateTimeString); - const time = date.toLocaleTimeString('en-US', { hc: 'h24' }); - return `${time}`; + return `${frequencyString}, ${this.$formatTime(timestamp, options)}`; }, }, diff --git a/packages/kolibri-common/components/SyncSchedule/__tests__/EditDeviceSyncSchedule.spec.js b/packages/kolibri-common/components/SyncSchedule/__tests__/EditDeviceSyncSchedule.spec.js new file mode 100644 index 00000000000..172d17396fd --- /dev/null +++ b/packages/kolibri-common/components/SyncSchedule/__tests__/EditDeviceSyncSchedule.spec.js @@ -0,0 +1,82 @@ +import { shallowMount } from '@vue/test-utils'; +import { oneWeek } from '../constants'; +import EditDeviceSyncSchedule from '../EditDeviceSyncSchedule'; + +describe('EditDeviceSyncSchedule', () => { + describe('computeNextSync', () => { + // generates test cases for combinations of day of week and half-hour time interval + // test cases focus on edge cases, rather than exhaustive testing, which would create a huge + // number of tests. + const testCases = []; + const days = [0, 3, 6]; // days of week (0 = Sunday, 1 = Monday, ..., 6 = Saturday) + const hours = [0, 8, 23]; + for (const nowDay of days) { + for (const nowHours of hours) { + for (const nowMinutes of [15, 45]) { + // current day/time + // Jan 4th is a Sunday in 1981 + const nowDate = 4 + nowDay; + const now = new Date(1981, 0, nowDate, nowHours, nowMinutes); // month is 0-indexed + for (const targetDay of days) { + for (const targetHours of hours) { + for (const targetMinutes of [0, 30]) { + const expected = new Date(1981, 0, nowDate, targetHours, targetMinutes); + if (targetDay === nowDay) { + // same day + if ( + targetHours > nowHours || + (targetHours === nowHours && targetMinutes > nowMinutes) + ) { + // same day, later time + } else { + // same day, earlier time + expected.setDate(expected.getDate() + 7); + } + } else if (targetDay > nowDay) { + // later day + expected.setDate(expected.getDate() + (targetDay - nowDay)); + } else { + // earlier day + expected.setDate(expected.getDate() + (7 - (nowDay - targetDay))); + } + // add to test cases + testCases.push([ + now.toISOString(), + targetHours, + targetMinutes, + targetDay, + expected.toISOString(), + ]); + } + } + } + } + } + } + test.each(testCases)( + 'should compute next sync date correctly given now=%s, selectedTime={hours:%i, minutes:%i}, selectedDay={value:%i}', + async (now, hours, minutes, selectedDay, expected) => { + // set up + EditDeviceSyncSchedule.created = () => Promise.resolve(); + const wrapper = shallowMount(EditDeviceSyncSchedule, { + data() { + return { + now: new Date(now), + selectedItem: { value: oneWeek }, + selectedTime: { value: 1, hours, minutes }, + selectedDay: { value: selectedDay }, + }; + }, + }); + + await wrapper.vm.$nextTick(); + + // action + const nextSyncDate = wrapper.vm.computeNextSync(); + + // assertion + expect(nextSyncDate.toISOString()).toBe(new Date(expected).toISOString()); + } + ); + }); +}); diff --git a/packages/kolibri-common/components/SyncSchedule/constants.js b/packages/kolibri-common/components/SyncSchedule/constants.js index 921d65bdfbc..2f5bbe290cf 100644 --- a/packages/kolibri-common/components/SyncSchedule/constants.js +++ b/packages/kolibri-common/components/SyncSchedule/constants.js @@ -2,3 +2,13 @@ export const SyncPageNames = { MANAGE_SYNC_SCHEDULE: 'MANAGE_SYNC_SCHEDULE', EDIT_SYNC_SCHEDULE: 'EDIT_SYNC_SCHEDULE', }; + +export const oneHour = 60 * 60; + +export const oneDay = oneHour * 24; + +export const oneWeek = oneDay * 7; + +export const twoWeeks = oneWeek * 2; + +export const oneMonth = oneWeek * 4; From 67eb9eeec98172b39249e0bc52a07c079acc80be Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 21 May 2023 17:14:54 -0700 Subject: [PATCH 02/11] Move handling of registration while selecting KDP into the SyncFacilityModalGroup. --- .../views/sync/ConfirmationRegisterModal.vue | 18 +++++++- .../src/views/sync/SyncFacilityModalGroup.vue | 46 +++++++++++++++++-- .../assets/src/views/FacilitiesPage/index.vue | 11 +---- .../views/DataPage/SyncInterface/index.vue | 7 +-- 4 files changed, 61 insertions(+), 21 deletions(-) diff --git a/kolibri/core/assets/src/views/sync/ConfirmationRegisterModal.vue b/kolibri/core/assets/src/views/sync/ConfirmationRegisterModal.vue index 753252df6bb..8017003c5ad 100644 --- a/kolibri/core/assets/src/views/sync/ConfirmationRegisterModal.vue +++ b/kolibri/core/assets/src/views/sync/ConfirmationRegisterModal.vue @@ -5,7 +5,7 @@ :submitText="registerText" :cancelText="cancelText" @submit="registerFacility" - @cancel="$emit('cancel')" + @cancel="cancel" > @@ -26,15 +43,21 @@ import commonSyncElements from 'kolibri.coreVue.mixins.commonSyncElements'; import SelectDeviceModalGroup from './SelectDeviceModalGroup'; import SelectSyncSourceModal from './SelectSyncSourceModal'; + import RegisterFacilityModal from './RegisterFacilityModal'; + import ConfirmationRegisterModal from './ConfirmationRegisterModal'; const Steps = Object.freeze({ SELECT_SOURCE: 'SELECT_SOURCE', SELECT_ADDRESS: 'SELECT_ADDRESS', + REGISTER_FACILITY: 'REGISTER_FACILITY', + CONFIRMATION_REGISTER: 'CONFIRMATION_REGISTER', }); export default { name: 'SyncFacilityModalGroup', components: { + ConfirmationRegisterModal, + RegisterFacilityModal, SelectSyncSourceModal, SelectDeviceModalGroup, }, @@ -49,18 +72,24 @@ }, data() { return { - step: null, + step: Steps.SELECT_SOURCE, syncSubmitDisabled: false, - displaySkipOption: true, + kdpProject: null, // { name, token } }; }, computed: { atSelectSource() { - return !this.step; + return this.step === Steps.SELECT_SOURCE; }, atSelectAddress() { return this.step === Steps.SELECT_ADDRESS; }, + atRegister() { + return this.step === Steps.REGISTER_FACILITY; + }, + atConfirmation() { + return this.step === Steps.CONFIRMATION_REGISTER; + }, }, methods: { handleSourceSubmit(data) { @@ -68,9 +97,9 @@ this.step = Steps.SELECT_ADDRESS; } else { if (this.facilityForSync.dataset.registered) { - this.$emit('syncKDP', this.facilityForSync); + this.emitKdpSync(); } else { - this.$emit('register', this.displaySkipOption, this.facilityForSync); + this.step = Steps.REGISTER_FACILITY; } } }, @@ -80,9 +109,16 @@ } this.$emit('syncPeer', data, this.facilityForSync); }, + handleValidateSuccess({ name, token }) { + this.kdpProject = { name, token }; + this.step = Steps.CONFIRMATION_REGISTER; + }, closeModal() { this.$emit('close'); }, + emitKdpSync() { + this.$emit('syncKDP', this.facilityForSync); + }, }, }; diff --git a/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue b/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue index ec932c4c157..128126b9786 100644 --- a/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue +++ b/kolibri/plugins/device/assets/src/views/FacilitiesPage/index.vue @@ -153,7 +153,7 @@ @@ -246,8 +245,6 @@ facilityForRegister: null, kdpProject: null, // { name, token } taskIdsToWatch: [], - displaySkipOption: false, - // (facilityTaskQueue) facilityTasks }; }, computed: { @@ -306,7 +303,6 @@ if (option === Options.REMOVE) { this.facilityForRemoval = facility; } else if (option === Options.REGISTER) { - this.displaySkipOption = false; this.facilityForRegister = facility; } else if (option === Options.MANAGESYNC) { const route = this.manageSync(facility.id); @@ -367,11 +363,6 @@ this.taskIdsToWatch.push(taskId); this.facilityForRemoval = null; }, - handleRegister(displaySkipOption, facility) { - this.displaySkipOption = displaySkipOption; - this.facilityForRegister = facility; - this.facilityForSync = null; - }, handleKDPSync(facility) { this.facilityForSync = null; this.facilityForRegister = null; diff --git a/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue b/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue index cc94ff516f0..85cf7ccced1 100644 --- a/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue +++ b/kolibri/plugins/facility/assets/src/views/DataPage/SyncInterface/index.vue @@ -84,7 +84,7 @@ @@ -169,7 +168,6 @@ syncHasFailed: false, Modals, isMenuOpen: false, - displaySkipOption: false, }; }, computed: { @@ -265,9 +263,8 @@ handleSyncFacilityFailure() { this.syncHasFailed = true; }, - handleRegister(displaySkipOption) { + handleRegister() { this.closeMenu(); - this.displaySkipOption = Boolean(displaySkipOption); this.displayModal(Modals.REGISTER_FACILITY); }, handleKDPSync() { From 122387b967161b5dc9914744a81ee679735a9913 Mon Sep 17 00:00:00 2001 From: Richard Tibbles Date: Sun, 21 May 2023 17:17:37 -0700 Subject: [PATCH 03/11] Add handling of KDP syncing to manage schedule sync component. --- .../SyncSchedule/ManageSyncSchedule.vue | 210 +++++++----------- .../components/SyncSchedule/constants.js | 2 + .../components/SyncSchedule/i18n.js | 10 + 3 files changed, 92 insertions(+), 130 deletions(-) create mode 100644 packages/kolibri-common/components/SyncSchedule/i18n.js diff --git a/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue b/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue index e58a17c397b..ff0d952493c 100644 --- a/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue +++ b/packages/kolibri-common/components/SyncSchedule/ManageSyncSchedule.vue @@ -40,7 +40,7 @@ - - - - - - - - - -
-
-
- - {{ btn.base_url }} -
-
-
-
-
- -
-
-
- - - - - -
-
- + :facilityForSync="facility" + @close="closeModal" + @syncKDP="handleKDPSync" + @syncPeer="handlePeerSync" + /> @@ -192,23 +122,52 @@