fix: Livechat analytics in a given date range consider conversation data from the following day (#33054)

pull/24889/head^2
Matheus Barbosa Silva 1 year ago committed by GitHub
parent be5d153682
commit a14c0678bb
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
  1. 6
      .changeset/strong-terms-love.md
  2. 16
      apps/meteor/server/models/raw/LivechatRooms.ts
  3. 14
      apps/meteor/server/services/omnichannel-analytics/AgentData.ts
  4. 2
      apps/meteor/server/services/omnichannel-analytics/ChartData.ts
  5. 4
      apps/meteor/server/services/omnichannel-analytics/OverviewData.ts
  6. 6
      apps/meteor/server/services/omnichannel-analytics/service.ts
  7. 196
      apps/meteor/tests/end-to-end/api/livechat/04-dashboards.ts
  8. 85
      apps/meteor/tests/unit/server/services/omnichannel-analytics/OverviewData.tests.ts
  9. 8
      packages/model-typings/src/models/ILivechatRoomsModel.ts

@ -0,0 +1,6 @@
---
"@rocket.chat/meteor": patch
"@rocket.chat/model-typings": patch
---
Fixed issue with livechat analytics in a given date range considering conversation data from the following day

@ -2046,12 +2046,12 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
return updater;
}
getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, { departmentId }: { departmentId?: string } = {}) {
getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lte: Date }, { departmentId }: { departmentId?: string } = {}) {
const query: Filter<IOmnichannelRoom> = {
t,
ts: {
$gte: new Date(date.gte), // ISO Date, ts >= date.gte
$lt: new Date(date.lt), // ISODate, ts < date.lt
$lte: new Date(date.lte), // ISODate, ts <= date.lte
},
...(departmentId && departmentId !== 'undefined' && { departmentId }),
};
@ -2061,7 +2061,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
getAnalyticsMetricsBetweenDate(
t: 'l',
date: { gte: Date; lt: Date },
date: { gte: Date; lte: Date },
{ departmentId }: { departmentId?: string } = {},
extraQuery: Document = {},
) {
@ -2069,7 +2069,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
t,
ts: {
$gte: new Date(date.gte), // ISO Date, ts >= date.gte
$lt: new Date(date.lt), // ISODate, ts < date.lt
$lte: new Date(date.lte), // ISODate, ts <= date.lte
},
...(departmentId && departmentId !== 'undefined' && { departmentId }),
...extraQuery,
@ -2082,7 +2082,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
getAnalyticsMetricsBetweenDateWithMessages(
t: string,
date: { gte: Date; lt: Date },
date: { gte: Date; lte: Date },
{ departmentId }: { departmentId?: string } = {},
extraQuery: Document = {},
extraMatchers: Document = {},
@ -2094,7 +2094,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
t,
ts: {
$gte: new Date(date.gte), // ISO Date, ts >= date.gte
$lt: new Date(date.lt), // ISODate, ts < date.lt
$lte: new Date(date.lte), // ISODate, ts <= date.lte
},
...(departmentId && departmentId !== 'undefined' && { departmentId }),
...extraMatchers,
@ -2164,7 +2164,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
);
}
getAnalyticsBetweenDate(date: { gte: Date; lt: Date }, { departmentId }: { departmentId?: string } = {}) {
getAnalyticsBetweenDate(date: { gte: Date; lte: Date }, { departmentId }: { departmentId?: string } = {}) {
return this.col.aggregate<Pick<IOmnichannelRoom, 'ts' | 'departmentId' | 'open' | 'servedBy' | 'metrics' | 'msgs' | 'onHold'>>(
[
{
@ -2172,7 +2172,7 @@ export class LivechatRoomsRaw extends BaseRaw<IOmnichannelRoom> implements ILive
t: 'l',
ts: {
$gte: new Date(date.gte), // ISO Date, ts >= date.gte
$lt: new Date(date.lt), // ISODate, ts < date.lt
$lte: new Date(date.lte), // ISODate, ts <= date.lte
},
...(departmentId && departmentId !== 'undefined' && { departmentId }),
},

@ -77,7 +77,7 @@ export class AgentOverviewData {
const agentConversations = new Map(); // stores total conversations for each agent
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
@ -128,7 +128,7 @@ export class AgentOverviewData {
const agentChatDurations = new Map(); // stores total conversations for each agent
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
@ -178,7 +178,7 @@ export class AgentOverviewData {
const agentMessages = new Map(); // stores total conversations for each agent
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
@ -220,7 +220,7 @@ export class AgentOverviewData {
const agentAvgRespTime = new Map(); // stores avg response time for each agent
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
@ -270,7 +270,7 @@ export class AgentOverviewData {
const agentFirstRespTime = new Map(); // stores avg response time for each agent
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
@ -312,7 +312,7 @@ export class AgentOverviewData {
const agentAvgRespTime = new Map(); // stores avg response time for each agent
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
const data: ConversationData = {
@ -362,7 +362,7 @@ export class AgentOverviewData {
const agentAvgReactionTime = new Map(); // stores avg reaction time for each agent
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
const data: ConversationData = {

@ -14,7 +14,7 @@ type ChartDataValidActions =
type DateParam = {
gte: Date;
lt: Date;
lte: Date;
};
export class ChartData {

@ -97,7 +97,7 @@ export class OverviewData {
const date = {
gte: moment.tz(from, timezone).startOf('day').utc(),
lt: moment.tz(to, timezone).endOf('day').utc(),
lte: moment.tz(to, timezone).endOf('day').utc(),
};
// @ts-expect-error - Check extraquery usage on this func
@ -181,7 +181,7 @@ export class OverviewData {
const date = {
gte: from.toDate(),
lt: to.add(1, 'days').toDate(),
lte: to.toDate(),
};
await this.roomsModel.getAnalyticsMetricsBetweenDate('l', date, { departmentId }, extraQuery).forEach(({ metrics }) => {

@ -111,13 +111,13 @@ export class OmnichannelAnalyticsService extends ServiceClassInternal implements
const hour = parseInt(m.add(currentHour ? 1 : 0, 'hour').format('H'));
const label = {
from: moment.utc().set({ hour }).tz(timezone).format('hA'),
to: moment.utc().set({ hour }).add(1, 'hour').tz(timezone).format('hA'),
to: moment.utc().set({ hour }).endOf('hour').tz(timezone).format('hA'),
};
data.dataLabels.push(`${label.from}-${label.to}`);
const date = {
gte: m.toDate(),
lt: moment(m).add(1, 'hours').toDate(),
lte: moment(m).endOf('hour').toDate(),
};
data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery));
@ -128,7 +128,7 @@ export class OmnichannelAnalyticsService extends ServiceClassInternal implements
const date = {
gte: m.toDate(),
lt: moment(m).add(1, 'days').toDate(),
lte: moment(m).endOf('day').toDate(),
};
data.dataPoints.push(await this.chart.callAction(chartLabel, date, departmentId, extraQuery));

@ -776,6 +776,147 @@ describe('LIVECHAT - dashboards', function () {
expect(user1Data).to.have.property('value', '28.57%');
expect(user2Data).to.have.property('value', '71.43%');
});
(IS_EE ? it : it.skip)('should only return results in the provided date interval when searching for total conversations', async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/agent-overview'))
.query({ from: yesterday, to: yesterday, name: 'Total_conversations', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.have.property('success', true);
expect(result.body).to.have.property('head');
expect(result.body.head).to.be.an('array').with.lengthOf(2);
expect(result.body.head[0]).to.have.property('name', 'Agent');
expect(result.body.head[1]).to.have.property('name', '%_of_conversations');
expect(result.body).to.have.property('data');
expect(result.body.data).to.be.an('array').that.is.empty;
});
(IS_EE ? it : it.skip)(
'should only return results in the provided date interval when searching for average chat durations',
async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/agent-overview'))
.query({ from: yesterday, to: yesterday, name: 'Avg_chat_duration', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.have.property('success', true);
expect(result.body).to.have.property('head');
expect(result.body.head).to.be.an('array').with.lengthOf(2);
expect(result.body.head[0]).to.have.property('name', 'Agent');
expect(result.body.head[1]).to.have.property('name', 'Avg_chat_duration');
expect(result.body).to.have.property('data');
expect(result.body.data).to.be.an('array').that.is.empty;
},
);
(IS_EE ? it : it.skip)('should only return results in the provided date interval when searching for total messages', async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/agent-overview'))
.query({ from: yesterday, to: yesterday, name: 'Total_messages', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.have.property('success', true);
expect(result.body).to.have.property('head');
expect(result.body.head).to.be.an('array').with.lengthOf(2);
expect(result.body.head[0]).to.have.property('name', 'Agent');
expect(result.body.head[1]).to.have.property('name', 'Total_messages');
expect(result.body).to.have.property('data');
expect(result.body.data).to.be.an('array').that.is.empty;
});
(IS_EE ? it : it.skip)(
'should only return results in the provided date interval when searching for average first response times',
async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/agent-overview'))
.query({ from: yesterday, to: yesterday, name: 'Avg_first_response_time', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.have.property('success', true);
expect(result.body).to.have.property('head');
expect(result.body.head).to.be.an('array').with.lengthOf(2);
expect(result.body.head[0]).to.have.property('name', 'Agent');
expect(result.body.head[1]).to.have.property('name', 'Avg_first_response_time');
expect(result.body).to.have.property('data');
expect(result.body.data).to.be.an('array').that.is.empty;
},
);
(IS_EE ? it : it.skip)(
'should only return results in the provided date interval when searching for best first response times',
async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/agent-overview'))
.query({ from: yesterday, to: yesterday, name: 'Best_first_response_time', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.have.property('success', true);
expect(result.body).to.have.property('head');
expect(result.body.head).to.be.an('array').with.lengthOf(2);
expect(result.body.head[0]).to.have.property('name', 'Agent');
expect(result.body.head[1]).to.have.property('name', 'Best_first_response_time');
expect(result.body).to.have.property('data');
expect(result.body.data).to.be.an('array').that.is.empty;
},
);
(IS_EE ? it : it.skip)(
'should only return results in the provided date interval when searching for average response times',
async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/agent-overview'))
.query({ from: yesterday, to: yesterday, name: 'Avg_response_time', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.have.property('success', true);
expect(result.body).to.have.property('head');
expect(result.body.head).to.be.an('array').with.lengthOf(2);
expect(result.body.head[0]).to.have.property('name', 'Agent');
expect(result.body.head[1]).to.have.property('name', 'Avg_response_time');
expect(result.body).to.have.property('data');
expect(result.body.data).to.be.an('array').that.is.empty;
},
);
(IS_EE ? it : it.skip)(
'should only return results in the provided date interval when searching for average reaction times',
async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/agent-overview'))
.query({ from: yesterday, to: yesterday, name: 'Avg_reaction_time', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.have.property('success', true);
expect(result.body).to.have.property('head');
expect(result.body.head).to.be.an('array').with.lengthOf(2);
expect(result.body.head[0]).to.have.property('name', 'Agent');
expect(result.body.head[1]).to.have.property('name', 'Avg_reaction_time');
expect(result.body).to.have.property('data');
expect(result.body.data).to.be.an('array').that.is.empty;
},
);
});
describe('[livechat/analytics/agent-overview] - Average first response time', () => {
@ -1050,5 +1191,60 @@ describe('LIVECHAT - dashboards', function () {
const totalMessagesValue = parseInt(totalMessages.value);
expect(totalMessagesValue).to.be.greaterThanOrEqual(minMessages);
});
(IS_EE ? it : it.skip)(
'should only consider conversations in the provided time range when returning analytics conversations overview data',
async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/overview'))
.query({ from: yesterday, to: yesterday, name: 'Conversations', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.be.an('array');
const expectedResult = [
{ title: 'Total_conversations', value: 0 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 0 },
{ title: 'Conversations_per_day', value: '0.00' },
];
expectedResult.forEach((expected) => {
const resultItem = result.body.find((item: any) => item.title === expected.title);
expect(resultItem).to.not.be.undefined;
expect(resultItem).to.have.property('value', expected.value);
});
},
);
(IS_EE ? it : it.skip)(
'should only consider conversations in the provided time range when returning analytics productivity overview data',
async () => {
const yesterday = moment().subtract(1, 'days').format('YYYY-MM-DD');
const result = await request
.get(api('livechat/analytics/overview'))
.query({ from: yesterday, to: yesterday, name: 'Productivity', departmentId: department._id })
.set(credentials)
.expect('Content-Type', 'application/json')
.expect(200);
expect(result.body).to.be.an('array');
const expectedResult = [
{ title: 'Avg_response_time', value: '00:00:00' },
{ title: 'Avg_first_response_time', value: '00:00:00' },
{ title: 'Avg_reaction_time', value: '00:00:00' },
];
expectedResult.forEach((expected) => {
const resultItem = result.body.find((item: any) => item.title === expected.title);
expect(resultItem).to.not.be.undefined;
expect(resultItem).to.have.property('value', expected.value);
});
},
);
});
});

@ -6,9 +6,9 @@ import sinon from 'sinon';
import { OverviewData } from '../../../../../server/services/omnichannel-analytics/OverviewData';
import { conversations } from './mockData';
const analytics = (date: { gte: Date; lt: Date }) => {
const analytics = (date: { gte: Date; lte: Date }) => {
// filter the mockData array with the date param with moment
return conversations.filter((c) => moment(c.ts).isBetween(date.gte, date.lt));
return conversations.filter((c) => moment(c.ts).isBetween(date.gte, date.lte, undefined, '[]'));
};
describe('OverviewData Analytics', () => {
@ -184,7 +184,7 @@ describe('OverviewData Analytics', () => {
});
it('should return all values as 0 when theres data but not on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: () => analytics({ gte: moment().set('month', 9).toDate(), lt: moment().set('month', 9).toDate() }),
getAnalyticsBetweenDate: () => analytics({ gte: moment().set('month', 9).toDate(), lte: moment().set('month', 9).toDate() }),
getOnHoldConversationsBetweenDate: () => 0,
} as any);
const result = await overview.Conversations(moment(), moment(), '', 'UTC', (v: string): string => v, {});
@ -200,7 +200,7 @@ describe('OverviewData Analytics', () => {
});
it('should return the correct values when theres data on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: (date: { gte: Date; lt: Date }) => analytics(date),
getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date),
getOnHoldConversationsBetweenDate: () => 1,
} as any);
@ -223,6 +223,47 @@ describe('OverviewData Analytics', () => {
{ title: 'Busiest_time', value: '11AM - 12PM' },
]);
});
it('should only return conversation metrics related to the provided period, and not consider previous or following days', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date),
getOnHoldConversationsBetweenDate: () => 1,
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 23);
// Fixed date to assure we get the same data
const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Total_conversations', value: 1 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 1 },
{ title: 'Total_messages', value: 14 },
{ title: 'Busiest_day', value: 'Thursday' },
{ title: 'Conversations_per_day', value: '1.00' },
{ title: 'Busiest_time', value: '7AM - 8AM' },
]);
});
it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => {
const overview = new OverviewData({
getAnalyticsBetweenDate: (date: { gte: Date; lte: Date }) => analytics(date),
getOnHoldConversationsBetweenDate: () => 0,
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13);
const result = await overview.Conversations(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Total_conversations', value: 0 },
{ title: 'Open_conversations', value: 0 },
{ title: 'On_Hold_conversations', value: 0 },
{ title: 'Total_messages', value: 0 },
{ title: 'Busiest_day', value: '-' },
{ title: 'Conversations_per_day', value: '0.00' },
{ title: 'Busiest_time', value: '-' },
]);
});
});
describe('Productivity', () => {
@ -241,7 +282,7 @@ describe('OverviewData Analytics', () => {
});
it('should return all values as 0 when theres data but not on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lt: Date }) => analytics(date),
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
const result = await overview.Productivity(
moment().set('month', 9),
@ -259,7 +300,7 @@ describe('OverviewData Analytics', () => {
});
it('should return the correct values when theres data on the period we pass', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lt: Date }) => analytics(date),
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
const result = await overview.Productivity(
moment().set('month', 10).set('year', 2023).startOf('month'),
@ -274,5 +315,37 @@ describe('OverviewData Analytics', () => {
{ title: 'Avg_reaction_time', value: '00:00:49' },
]);
});
it('should only return productivity metrics related to the provided period, and not consider previous or following days', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment().set('month', 10).set('year', 2023).set('date', 25);
const result = await overview.Productivity(targetDate.startOf('day'), targetDate.clone().endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Avg_response_time', value: '00:00:01' },
{ title: 'Avg_first_response_time', value: '00:00:04' },
{ title: 'Avg_reaction_time', value: '00:02:03' },
]);
});
it('should return all values as 0 when there is no data in the provided period, but there is data in the previous and following days', async () => {
const overview = new OverviewData({
getAnalyticsMetricsBetweenDate: (_: any, date: { gte: Date; lte: Date }) => analytics(date),
} as any);
// choosing this specific date since the day before and after are not empty
const targetDate = moment.utc().set('month', 10).set('year', 2023).set('date', 13);
const result = await overview.Productivity(targetDate.startOf('day'), targetDate.endOf('day'), '', 'UTC');
expect(result).to.be.deep.equal([
{ title: 'Avg_response_time', value: '00:00:00' },
{ title: 'Avg_first_response_time', value: '00:00:00' },
{ title: 'Avg_reaction_time', value: '00:00:00' },
]);
});
});
});

@ -225,22 +225,22 @@ export interface ILivechatRoomsModel extends IBaseModel<IOmnichannelRoom> {
message: IMessage,
updater?: Updater<IOmnichannelRoom>,
): Updater<IOmnichannelRoom>;
getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lt: Date }, data?: { departmentId: string }): Promise<number>;
getTotalConversationsBetweenDate(t: 'l', date: { gte: Date; lte: Date }, data?: { departmentId: string }): Promise<number>;
getAnalyticsMetricsBetweenDate(
t: 'l',
date: { gte: Date; lt: Date },
date: { gte: Date; lte: Date },
data?: { departmentId?: string },
extraQuery?: Filter<IOmnichannelRoom>,
): FindCursor<Pick<IOmnichannelRoom, 'ts' | 'departmentId' | 'open' | 'servedBy' | 'metrics' | 'msgs'>>;
getAnalyticsMetricsBetweenDateWithMessages(
t: string,
date: { gte: Date; lt: Date },
date: { gte: Date; lte: Date },
data?: { departmentId?: string },
extraQuery?: Document,
extraMatchers?: Document,
): AggregationCursor<Pick<IOmnichannelRoom, '_id' | 'ts' | 'departmentId' | 'open' | 'servedBy' | 'metrics' | 'msgs'>>;
getAnalyticsBetweenDate(
date: { gte: Date; lt: Date },
date: { gte: Date; lte: Date },
data?: { departmentId: string },
): AggregationCursor<Pick<IOmnichannelRoom, 'ts' | 'departmentId' | 'open' | 'servedBy' | 'metrics' | 'msgs' | 'onHold'>>;
findOpenByAgent(userId: string, extraQuery?: Filter<IOmnichannelRoom>): FindCursor<IOmnichannelRoom>;

Loading…
Cancel
Save