diff --git a/app/builders/v2/report_builder.rb b/app/builders/v2/report_builder.rb index 5cd8b4a63..b786ba25c 100644 --- a/app/builders/v2/report_builder.rb +++ b/app/builders/v2/report_builder.rb @@ -74,7 +74,7 @@ class V2::ReportBuilder :created_at, default_value: 0, range: range, - permit: %w[day week month year], + permit: %w[day week month year hour], time_zone: @timezone ) end diff --git a/app/javascript/dashboard/api/reports.js b/app/javascript/dashboard/api/reports.js index 61fd0adca..6b3249e25 100644 --- a/app/javascript/dashboard/api/reports.js +++ b/app/javascript/dashboard/api/reports.js @@ -8,15 +8,15 @@ class ReportsAPI extends ApiClient { super('reports', { accountScoped: true, apiVersion: 'v2' }); } - getReports( + getReports({ metric, since, until, type = 'account', id, group_by, - business_hours - ) { + business_hours, + }) { return axios.get(`${this.url}`, { params: { metric, diff --git a/app/javascript/dashboard/api/specs/reports.spec.js b/app/javascript/dashboard/api/specs/reports.spec.js index 4e2a8f38f..602e5a67d 100644 --- a/app/javascript/dashboard/api/specs/reports.spec.js +++ b/app/javascript/dashboard/api/specs/reports.spec.js @@ -20,7 +20,11 @@ describe('#Reports API', () => { }); describeWithAPIMock('API calls', context => { it('#getAccountReports', () => { - reportsAPI.getReports('conversations_count', 1621103400, 1621621800); + reportsAPI.getReports({ + metric: 'conversations_count', + since: 1621103400, + until: 1621621800, + }); expect(context.axiosMock.get).toHaveBeenCalledWith('/api/v2/reports', { params: { metric: 'conversations_count', diff --git a/app/javascript/dashboard/i18n/locale/en/report.json b/app/javascript/dashboard/i18n/locale/en/report.json index 1dbd24efd..6ff093260 100644 --- a/app/javascript/dashboard/i18n/locale/en/report.json +++ b/app/javascript/dashboard/i18n/locale/en/report.json @@ -390,10 +390,16 @@ "ACCOUNT_CONVERSATIONS": { "HEADER": "Open Conversations", "LOADING_MESSAGE": "Loading conversation metrics...", - "OPEN" : "Open", + "OPEN": "Open", "UNATTENDED": "Unattended", "UNASSIGNED": "Unassigned" }, + "CONVERSATION_HEATMAP": { + "HEADER": "Conversation Traffic", + "NO_CONVERSATIONS": "No conversations", + "CONVERSATION": "%{count} conversation", + "CONVERSATIONS": "%{count} conversations" + }, "AGENT_CONVERSATIONS": { "HEADER": "Conversations by agents", "LOADING_MESSAGE": "Loading agent metrics...", @@ -411,5 +417,14 @@ "BUSY": "Busy", "OFFLINE": "Offline" } + }, + "DAYS_OF_WEEK": { + "SUNDAY": "Sunday", + "MONDAY": "Monday", + "TUESDAY": "Tuesday", + "WEDNESDAY": "Wednesday", + "THURSDAY": "Thursday", + "FRIDAY": "Friday", + "SATURDAY": "Saturday" } } diff --git a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue index 15db4d805..f496e1b97 100644 --- a/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue +++ b/app/javascript/dashboard/routes/dashboard/settings/reports/Index.vue @@ -8,7 +8,6 @@ > {{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }} - +
+ + + +
+
+ + +
+ + + + diff --git a/app/javascript/dashboard/store/modules/reports.js b/app/javascript/dashboard/store/modules/reports.js index 4d4fee959..b51c50113 100644 --- a/app/javascript/dashboard/store/modules/reports.js +++ b/app/javascript/dashboard/store/modules/reports.js @@ -4,6 +4,10 @@ import Report from '../../api/reports'; import { downloadCsvFile } from '../../helper/downloadHelper'; import AnalyticsHelper from '../../helper/AnalyticsHelper'; import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events'; +import { + reconcileHeatmapData, + clampDataBetweenTimeline, +} from 'helpers/ReportsDataHelper'; const state = { fetchingStatus: false, @@ -24,9 +28,11 @@ const state = { overview: { uiFlags: { isFetchingAccountConversationMetric: false, + isFetchingAccountConversationsHeatmap: false, isFetchingAgentConversationMetric: false, }, accountConversationMetric: {}, + accountConversationHeatmap: [], agentConversationMetric: [], }, }; @@ -41,6 +47,9 @@ const getters = { getAccountConversationMetric(_state) { return _state.overview.accountConversationMetric; }, + getAccountConversationHeatmapData(_state) { + return _state.overview.accountConversationHeatmap; + }, getAgentConversationMetric(_state) { return _state.overview.agentConversationMetric; }, @@ -52,24 +61,28 @@ const getters = { export const actions = { fetchAccountReport({ commit }, reportObj) { commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, true); - Report.getReports( - reportObj.metric, - reportObj.from, - reportObj.to, - reportObj.type, - reportObj.id, - reportObj.groupBy, - reportObj.businessHours - ).then(accountReport => { + Report.getReports(reportObj).then(accountReport => { let { data } = accountReport; - data = data.filter( - el => - reportObj.to - el.timestamp > 0 && el.timestamp - reportObj.from >= 0 - ); + data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to); commit(types.default.SET_ACCOUNT_REPORTS, data); commit(types.default.TOGGLE_ACCOUNT_REPORT_LOADING, false); }); }, + fetchAccountConversationHeatmap({ commit }, reportObj) { + commit(types.default.TOGGLE_HEATMAP_LOADING, true); + Report.getReports({ ...reportObj, group_by: 'hour' }).then(heatmapData => { + let { data } = heatmapData; + data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to); + + data = reconcileHeatmapData( + data, + state.overview.accountConversationHeatmap + ); + + commit(types.default.SET_HEATMAP_DATA, data); + commit(types.default.TOGGLE_HEATMAP_LOADING, false); + }); + }, fetchAccountSummary({ commit }, reportObj) { Report.getSummary( reportObj.from, @@ -172,9 +185,15 @@ const mutations = { [types.default.SET_ACCOUNT_REPORTS](_state, accountReport) { _state.accountReport.data = accountReport; }, + [types.default.SET_HEATMAP_DATA](_state, heatmapData) { + _state.overview.accountConversationHeatmap = heatmapData; + }, [types.default.TOGGLE_ACCOUNT_REPORT_LOADING](_state, flag) { _state.accountReport.isFetching = flag; }, + [types.default.TOGGLE_HEATMAP_LOADING](_state, flag) { + _state.overview.uiFlags.isFetchingAccountConversationsHeatmap = flag; + }, [types.default.SET_ACCOUNT_SUMMARY](_state, summaryData) { _state.accountSummary = summaryData; }, diff --git a/app/javascript/dashboard/store/mutation-types.js b/app/javascript/dashboard/store/mutation-types.js index 1dc0b997d..17b01a453 100644 --- a/app/javascript/dashboard/store/mutation-types.js +++ b/app/javascript/dashboard/store/mutation-types.js @@ -143,6 +143,8 @@ export default { // Reports SET_ACCOUNT_REPORTS: 'SET_ACCOUNT_REPORTS', + SET_HEATMAP_DATA: 'SET_HEATMAP_DATA', + TOGGLE_HEATMAP_LOADING: 'TOGGLE_HEATMAP_LOADING', SET_ACCOUNT_SUMMARY: 'SET_ACCOUNT_SUMMARY', TOGGLE_ACCOUNT_REPORT_LOADING: 'TOGGLE_ACCOUNT_REPORT_LOADING', SET_ACCOUNT_CONVERSATION_METRIC: 'SET_ACCOUNT_CONVERSATION_METRIC', diff --git a/app/javascript/shared/helpers/ReportsDataHelper.js b/app/javascript/shared/helpers/ReportsDataHelper.js new file mode 100644 index 000000000..5bfd31861 --- /dev/null +++ b/app/javascript/shared/helpers/ReportsDataHelper.js @@ -0,0 +1,110 @@ +import { + fromUnixTime, + startOfDay, + endOfDay, + getUnixTime, + subDays, +} from 'date-fns'; + +/** + * Returns a key-value pair of timestamp and value for heatmap data + * + * @param {Array} data - An array of objects containing timestamp and value + * @returns {Object} - An object with timestamp as keys and corresponding values as values + */ +export const flattenHeatmapData = data => { + return data.reduce((acc, curr) => { + acc[curr.timestamp] = curr.value; + return acc; + }, {}); +}; + +/** + * Filter the given array to remove data outside the timeline + * + * @param {Array} data - An array of objects containing timestamp and value + * @param {number} from - Unix timestamp + * @param {number} to - Unix timestamp + * @returns {Array} - An array of objects containing timestamp and value + */ +export const clampDataBetweenTimeline = (data, from, to) => { + if (from === undefined && to === undefined) { + return data; + } + + return data.filter(el => { + const { timestamp } = el; + + const isWithinFrom = from === undefined || timestamp - from >= 0; + const isWithinTo = to === undefined || to - timestamp > 0; + + return isWithinFrom && isWithinTo; + }); +}; + +/** + * Generates an array of objects with timestamp and value as 0 for the last 7 days + * + * @returns {Array} - An array of objects containing timestamp and value + */ +export const generateEmptyHeatmapData = () => { + const data = []; + const today = new Date(); + + let timeMarker = getUnixTime(startOfDay(subDays(today, 6))); + let endOfToday = getUnixTime(endOfDay(today)); + + const oneHour = 3600; + + while (timeMarker <= endOfToday) { + data.push({ value: 0, timestamp: timeMarker }); + timeMarker += oneHour; + } + + return data; +}; + +/** + * Reconciles new data with existing heatmap data based on timestamps + * + * @param {Array} data - An array of objects containing timestamp and value + * @param {Array} heatmapData - An array of objects containing timestamp, value and other properties + * @returns {Array} - An array of objects with updated values + */ +export const reconcileHeatmapData = (data, dataFromStore) => { + const parsedData = flattenHeatmapData(data); + // make a copy of the data from store + const heatmapData = dataFromStore.length + ? dataFromStore + : generateEmptyHeatmapData(); + + return heatmapData.map(dataItem => { + if (parsedData[dataItem.timestamp]) { + dataItem.value = parsedData[dataItem.timestamp]; + } + return dataItem; + }); +}; + +/** + * Groups heatmap data by day + * + * @param {Array} heatmapData - An array of objects containing timestamp, value and other properties + * @returns {Map} - A Map object with dates as keys and corresponding data objects as values + */ +export const groupHeatmapByDay = heatmapData => { + return heatmapData.reduce((acc, data) => { + const date = fromUnixTime(data.timestamp); + const mapKey = startOfDay(date).toISOString(); + const dataToAppend = { + ...data, + date: fromUnixTime(data.timestamp), + hour: date.getHours(), + }; + if (!acc.has(mapKey)) { + acc.set(mapKey, []); + } + acc.get(mapKey).push(dataToAppend); + return acc; + }, new Map()); +}; diff --git a/app/javascript/shared/helpers/specs/ReportsDataHelper.spec.js b/app/javascript/shared/helpers/specs/ReportsDataHelper.spec.js new file mode 100644 index 000000000..9ac4bb699 --- /dev/null +++ b/app/javascript/shared/helpers/specs/ReportsDataHelper.spec.js @@ -0,0 +1,204 @@ +import { + groupHeatmapByDay, + reconcileHeatmapData, + flattenHeatmapData, + clampDataBetweenTimeline, +} from '../ReportsDataHelper'; + +describe('flattenHeatmapData', () => { + it('should flatten heatmap data to key-value pairs', () => { + const data = [ + { timestamp: 1614265200, value: 10 }, + { timestamp: 1614308400, value: 20 }, + ]; + const expected = { + 1614265200: 10, + 1614308400: 20, + }; + expect(flattenHeatmapData(data)).toEqual(expected); + }); + + it('should handle empty data', () => { + const data = []; + const expected = {}; + expect(flattenHeatmapData(data)).toEqual(expected); + }); + + it('should handle data with same timestamps', () => { + const data = [ + { timestamp: 1614265200, value: 10 }, + { timestamp: 1614265200, value: 20 }, + ]; + const expected = { + 1614265200: 20, + }; + expect(flattenHeatmapData(data)).toEqual(expected); + }); +}); + +describe('reconcileHeatmapData', () => { + it('should reconcile heatmap data with new data', () => { + const data = [ + { timestamp: 1614265200, value: 10 }, + { timestamp: 1614308400, value: 20 }, + ]; + const heatmapData = [ + { timestamp: 1614265200, value: 5 }, + { timestamp: 1614308400, value: 15 }, + { timestamp: 1614387600, value: 25 }, + ]; + const expected = [ + { timestamp: 1614265200, value: 10 }, + { timestamp: 1614308400, value: 20 }, + { timestamp: 1614387600, value: 25 }, + ]; + expect(reconcileHeatmapData(data, heatmapData)).toEqual(expected); + }); + + it('should reconcile heatmap data with new data and handle missing data', () => { + const data = [{ timestamp: 1614308400, value: 20 }]; + const heatmapData = [ + { timestamp: 1614265200, value: 5 }, + { timestamp: 1614308400, value: 15 }, + { timestamp: 1614387600, value: 25 }, + ]; + const expected = [ + { timestamp: 1614265200, value: 5 }, + { timestamp: 1614308400, value: 20 }, + { timestamp: 1614387600, value: 25 }, + ]; + expect(reconcileHeatmapData(data, heatmapData)).toEqual(expected); + }); + + it('should replace empty heatmap data with a new array', () => { + const data = [{ timestamp: 1614308400, value: 20 }]; + const heatmapData = []; + expect(reconcileHeatmapData(data, heatmapData).length).toEqual(7 * 24); + }); +}); + +describe('groupHeatmapByDay', () => { + it('should group heatmap data by day', () => { + const heatmapData = [ + { timestamp: 1614265200, value: 10 }, + { timestamp: 1614308400, value: 20 }, + { timestamp: 1614387600, value: 30 }, + { timestamp: 1614430800, value: 40 }, + { timestamp: 1614499200, value: 50 }, + ]; + + expect(groupHeatmapByDay(heatmapData)).toMatchInlineSnapshot(` + Map { + "2021-02-25T00:00:00.000Z" => Array [ + Object { + "date": 2021-02-25T15:00:00.000Z, + "hour": 15, + "timestamp": 1614265200, + "value": 10, + }, + ], + "2021-02-26T00:00:00.000Z" => Array [ + Object { + "date": 2021-02-26T03:00:00.000Z, + "hour": 3, + "timestamp": 1614308400, + "value": 20, + }, + ], + "2021-02-27T00:00:00.000Z" => Array [ + Object { + "date": 2021-02-27T01:00:00.000Z, + "hour": 1, + "timestamp": 1614387600, + "value": 30, + }, + Object { + "date": 2021-02-27T13:00:00.000Z, + "hour": 13, + "timestamp": 1614430800, + "value": 40, + }, + ], + "2021-02-28T00:00:00.000Z" => Array [ + Object { + "date": 2021-02-28T08:00:00.000Z, + "hour": 8, + "timestamp": 1614499200, + "value": 50, + }, + ], + } + `); + }); + + it('should group empty heatmap data by day', () => { + const heatmapData = []; + const expected = new Map(); + expect(groupHeatmapByDay(heatmapData)).toEqual(expected); + }); + + it('should group heatmap data with same timestamp in the same day', () => { + const heatmapData = [ + { timestamp: 1614265200, value: 10 }, + { timestamp: 1614265200, value: 20 }, + ]; + + expect(groupHeatmapByDay(heatmapData)).toMatchInlineSnapshot(` + Map { + "2021-02-25T00:00:00.000Z" => Array [ + Object { + "date": 2021-02-25T15:00:00.000Z, + "hour": 15, + "timestamp": 1614265200, + "value": 10, + }, + Object { + "date": 2021-02-25T15:00:00.000Z, + "hour": 15, + "timestamp": 1614265200, + "value": 20, + }, + ], + } + `); + }); +}); + +describe('clampDataBetweenTimeline', () => { + const data = [ + { timestamp: 1646054400, value: 'A' }, + { timestamp: 1646054500, value: 'B' }, + { timestamp: 1646054600, value: 'C' }, + { timestamp: 1646054700, value: 'D' }, + { timestamp: 1646054800, value: 'E' }, + ]; + + it('should return empty array if data is empty', () => { + expect(clampDataBetweenTimeline([], 1646054500, 1646054700)).toEqual([]); + }); + + it('should return empty array if no data is within the timeline', () => { + expect(clampDataBetweenTimeline(data, 1646054900, 1646055000)).toEqual([]); + }); + + it('should return the data as is no time limits are provider', () => { + expect(clampDataBetweenTimeline(data)).toEqual(data); + }); + + it('should return all data if all data is within the timeline', () => { + expect(clampDataBetweenTimeline(data, 1646054300, 1646054900)).toEqual( + data + ); + }); + + it('should return only data within the timeline', () => { + expect(clampDataBetweenTimeline(data, 1646054500, 1646054700)).toEqual([ + { timestamp: 1646054500, value: 'B' }, + { timestamp: 1646054600, value: 'C' }, + ]); + }); + + it('should return empty array if from and to are the same', () => { + expect(clampDataBetweenTimeline(data, 1646054500, 1646054500)).toEqual([]); + }); +}); diff --git a/package.json b/package.json index 8b1ce3b45..6f19d9fc2 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ "dependencies": { "@braid/vue-formulate": "^2.5.2", "@chatwoot/prosemirror-schema": "https://github.com/chatwoot/prosemirror-schema.git", - "@chatwoot/utils": "^0.0.11", + "@chatwoot/utils": "^0.0.12", "@hcaptcha/vue-hcaptcha": "^0.3.2", "@june-so/analytics-next": "^1.36.5", "@rails/actioncable": "6.1.3", diff --git a/yarn.lock b/yarn.lock index d31a238c1..98eada5a3 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1410,10 +1410,10 @@ prosemirror-utils "^0.9.6" prosemirror-view "^1.17.2" -"@chatwoot/utils@^0.0.11": - version "0.0.11" - resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.11.tgz#6922492e21c20bdb0ef733967a0b94829d8f620f" - integrity sha512-uiLsuBYTlZGXJ/d7QfJ+hlO1u7U1750ON5iu0pus8t6GlJQdxvMQWuf6fHQtfsDNcvL1aXsQu3H6BUk/nVZLlw== +"@chatwoot/utils@^0.0.12": + version "0.0.12" + resolved "https://registry.yarnpkg.com/@chatwoot/utils/-/utils-0.0.12.tgz#684fb5a475c1c7fdeaa628091e90c7d1decd156e" + integrity sha512-3O4zC4SO4z4rD2Chno+pzUUb/GacHQfIBfLCOsHviNzgljUE+neyOhS91yWDfCcd3Y0WmQs7UuXgvulmkdxqKg== dependencies: date-fns "^2.29.1"