feat: add Conversation traffic heatmap (#6508)

* feat: add heatmap component

* feat: add heatmap component

* feat: add dummy heatmap

* refactor: compact tiles

* feat: allow hour

* feat: wire up heatmap query

* feat: allow arbritrary number of weeks

* feat: update position of the widget

* chore: update heatmap title

* refactor: move traffic heatmap to overview

* chore: add comment for perf

* feat: add reconcile logic for heatmap fetching

Fetching the data for the last 6 days all the time is wasteful
So we fetch only the data for today and reconcile it with the data we already have

* refactor: re-org code for new utils

* feat: add translations

* feat: translate days of the week

* chore: update chatwoot utils

* feat: add markers to heatmap

* refactor: update class names

* refactor: move flatten as a separate method

* test: Heatmap Helpers

* chore: add comments

* refactor: method naming

* refactor: use heatmap-level mixin

* refactor: cleanup css

* chore: remove log

* refactor: reports.js to use object instead of separate params

* refactor: report store to use new API design

* refactor: rename HeatmapHelper -> ReportsDataHelper

* refactor: separate clampDataBetweenTimeline

* feat: add tests

* fix: group by hour

* feat: add scroll for smaller screens

* refactor: add base data to reconcile with

* fix: tests

* fix: overflow only on smaller screens

* feat: translate tooltip

* refactor: simplify reconcile

* chore: add docs

* chore: remoev heatmap from account report

* feat: let Heatmap handle loading state

* chore: Apply suggestions from code review

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>

* feat: update css

* refactor: color assignment to range

* feat: add short circuit

* Update app/javascript/dashboard/routes/dashboard/settings/reports/components/Heatmap.vue

---------

Co-authored-by: Muhsin Keloth <muhsinkeramam@gmail.com>
Co-authored-by: Nithin David Thomas <1277421+nithindavid@users.noreply.github.com>
Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
This commit is contained in:
Shivam Mishra
2023-03-07 09:01:58 +05:30
committed by GitHub
parent 2abc57300c
commit c88792f4a3
13 changed files with 733 additions and 25 deletions

View File

@@ -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

View File

@@ -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,

View File

@@ -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',

View File

@@ -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"
}
}

View File

@@ -8,7 +8,6 @@
>
{{ $t('REPORT.DOWNLOAD_AGENT_REPORTS') }}
</woot-button>
<report-filter-selector
group-by-filter
:selected-group-by-filter="selectedGroupByFilter"
@@ -70,6 +69,7 @@ const REPORTS_KEYS = {
};
export default {
name: 'ConversationReports',
components: {
ReportFilterSelector,
},

View File

@@ -36,6 +36,16 @@
</metric-card>
</div>
</div>
<div class="row">
<metric-card
:header="this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')"
>
<report-heatmap
:heat-data="accountConversationHeatmap"
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
/>
</metric-card>
</div>
<div class="row">
<metric-card
:header="this.$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')"
@@ -56,11 +66,19 @@ import { mapGetters } from 'vuex';
import AgentTable from './components/overview/AgentTable';
import MetricCard from './components/overview/MetricCard';
import { OVERVIEW_METRICS } from './constants';
import ReportHeatmap from './components/Heatmap';
import endOfDay from 'date-fns/endOfDay';
import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay';
import subDays from 'date-fns/subDays';
export default {
name: 'LiveReports',
components: {
AgentTable,
MetricCard,
ReportHeatmap,
},
data() {
return {
@@ -73,6 +91,7 @@ export default {
agents: 'agents/getAgents',
accountConversationMetric: 'getAccountConversationMetric',
agentConversationMetric: 'getAgentConversationMetric',
accountConversationHeatmap: 'getAccountConversationHeatmapData',
uiFlags: 'getOverviewUIFlags',
}),
agentStatusMetrics() {
@@ -108,6 +127,34 @@ export default {
fetchAllData() {
this.fetchAccountConversationMetric();
this.fetchAgentConversationMetric();
this.fetchHeatmapData();
},
fetchHeatmapData() {
if (this.uiFlags.isFetchingAccountConversationsHeatmap) {
return;
}
// the data for the last 6 days won't ever change,
// so there's no need to fetch it again
// but we can write some logic to check if the data is already there
// if it is there, we can refetch data only for today all over again
// and reconcile it with the rest of the data
// this will reduce the load on the server doing number crunching
let to = endOfDay(new Date());
let from = startOfDay(subDays(to, 6));
if (this.accountConversationHeatmap.length) {
to = endOfDay(new Date());
from = startOfDay(to);
}
this.$store.dispatch('fetchAccountConversationHeatmap', {
metric: 'conversations_count',
from: getUnixTime(from),
to: getUnixTime(to),
groupBy: 'hour',
businessHours: false,
});
},
fetchAccountConversationMetric() {
this.$store.dispatch('fetchAccountConversationMetric', {

View File

@@ -0,0 +1,307 @@
<template>
<div class="heatmap-container">
<template v-if="isLoading">
<div class="heatmap-labels">
<div
v-for="ii in 7"
:key="ii"
class="loading-cell heatmap-axis-label"
/>
</div>
<div class="heatmap-grid">
<div v-for="ii in 7" :key="ii" class="heatmap-grid-row">
<div v-for="jj in 24" :key="jj" class="heatmap-tile loading-cell">
<div class="heatmap-tile__label loading-cell" />
</div>
</div>
</div>
<div class="heatmap-timeline" />
<div class="heatmap-markers">
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} {{ ii }}</div>
</div>
</template>
<template v-else>
<div class="heatmap-labels">
<div
v-for="dateKey in processedData.keys()"
:key="dateKey"
class="heatmap-axis-label"
>
{{ getDayOfTheWeek(new Date(dateKey)) }}
<time>{{ formatDate(dateKey) }}</time>
</div>
</div>
<div class="heatmap-grid">
<div
v-for="dateKey in processedData.keys()"
:key="dateKey"
class="heatmap-grid-row"
>
<div
v-for="data in processedData.get(dateKey)"
:key="data.timestamp"
v-tooltip.top="getCountTooltip(data.value)"
class="heatmap-tile"
:class="getHeatmapLevelClass(data.value)"
>
<div class="heatmap-tile__label" />
</div>
</div>
</div>
<div class="heatmap-timeline" />
<div class="heatmap-markers">
<div v-for="ii in 24" :key="ii">{{ ii - 1 }} {{ ii }}</div>
</div>
</template>
</div>
</template>
<script>
import { getQuantileIntervals } from '@chatwoot/utils';
import format from 'date-fns/format';
import getDay from 'date-fns/getDay';
import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
export default {
name: 'Heatmap',
props: {
heatData: {
type: Array,
default: () => [],
},
isLoading: {
type: Boolean,
default: false,
},
},
computed: {
processedData() {
return groupHeatmapByDay(this.heatData);
},
quantileRange() {
const flattendedData = this.heatData.map(data => data.value);
return getQuantileIntervals(flattendedData, [
0.2,
0.4,
0.6,
0.8,
0.9,
0.99,
]);
},
},
methods: {
getCountTooltip(value) {
if (!value) {
return this.$t(
'OVERVIEW_REPORTS.CONVERSATION_HEATMAP.NO_CONVERSATIONS'
);
}
if (value === 1) {
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATION', {
count: value,
});
}
return this.$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.CONVERSATIONS', {
count: value,
});
},
formatDate(dateString) {
return format(new Date(dateString), 'MMM d, yyyy');
},
getDayOfTheWeek(date) {
const dayIndex = getDay(date);
const days = [
this.$t('DAYS_OF_WEEK.SUNDAY'),
this.$t('DAYS_OF_WEEK.MONDAY'),
this.$t('DAYS_OF_WEEK.TUESDAY'),
this.$t('DAYS_OF_WEEK.WEDNESDAY'),
this.$t('DAYS_OF_WEEK.THURSDAY'),
this.$t('DAYS_OF_WEEK.FRIDAY'),
this.$t('DAYS_OF_WEEK.SATURDAY'),
];
return days[dayIndex];
},
getHeatmapLevelClass(value) {
if (!value) return '';
const level = [...this.quantileRange, Infinity].findIndex(
range => value <= range && value > 0
);
if (level > 6) {
return 'l6';
}
return `l${level}`;
},
},
};
</script>
<style scoped lang="scss">
$heatmap-colors: (
level-1: var(--w-50),
level-2: var(--w-100),
level-3: var(--w-300),
level-4: var(--w-500),
level-5: var(--w-700),
level-6: var(--w-900),
);
$heatmap-hover-border-color: (
level-1: var(--w-25),
level-2: var(--w-50),
level-3: var(--w-100),
level-4: var(--w-300),
level-5: var(--w-500),
level-6: var(--w-700),
);
$tile-height: 3rem;
$tile-gap: var(--space-smaller);
$container-gap-row: var(--space-one);
$container-gap-column: var(--space-two);
$marker-height: var(--space-two);
@mixin heatmap-level($level) {
$color: map-get($heatmap-colors, 'level-#{$level}');
background-color: $color;
&:hover {
border: 1px solid map-get($heatmap-hover-border-color, 'level-#{$level}');
}
}
@media screen and (max-width: 768px) {
.heatmap-container {
overflow-y: auto;
}
}
.loading-cell {
background-color: var(--color-background-light);
border: 0px;
animation: loading-pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes loading-pulse {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0;
}
}
.heatmap-container {
display: grid;
position: relative;
width: 100%;
gap: $container-gap-row $container-gap-column;
grid-template-columns: 80px 1fr;
min-height: calc(
7 * #{$tile-height} + 6 * #{$tile-gap} + #{$container-gap-row} + #{$marker-height}
);
}
.heatmap-labels {
display: grid;
grid-template-rows: 1fr;
gap: $tile-gap;
flex-shrink: 0;
.heatmap-axis-label {
height: $tile-height;
min-width: 70px;
font-size: var(--font-size-micro);
font-weight: var(--font-weight-bold);
display: flex;
flex-direction: column;
align-items: end;
justify-content: center;
time {
font-size: var(--font-size-micro);
font-weight: var(--font-weight-normal);
}
}
}
.heatmap-grid {
display: grid;
grid-template-rows: 1fr;
gap: $tile-gap;
min-width: 700px;
width: 100%;
.heatmap-grid-row {
display: grid;
gap: $tile-gap;
grid-template-columns: repeat(24, 1fr);
}
.heatmap-tile {
width: auto;
height: $tile-height;
border-radius: var(--border-radius-normal);
&:hover {
box-shadow: var(--shadow-large);
transform: translateY(-2px);
transition: transform 0.2s ease-in-out, box-shadow 0.2s ease-in-out;
}
&:not(.l1):not(.l2):not(.l3):not(.l4):not(.l5):not(.l6) {
background-color: var(--color-background-light);
border: 1px solid var(--color-border-light);
&:hover {
transform: translateY(0);
box-shadow: none;
border: 1px solid var(--color-border-light);
}
}
&.l1 {
@include heatmap-level(1);
}
&.l2 {
@include heatmap-level(2);
}
&.l3 {
@include heatmap-level(3);
}
&.l4 {
@include heatmap-level(4);
}
&.l5 {
@include heatmap-level(5);
}
&.l6 {
@include heatmap-level(6);
}
}
}
.heatmap-markers {
display: grid;
grid-template-columns: repeat(24, 1fr);
gap: $tile-gap;
width: 100%;
font-size: var(--font-size-nano);
font-weight: var(--font-weight-bold);
height: $marker-height;
color: var(--color-body);
div {
display: flex;
align-items: center;
justify-content: center;
}
}
</style>

View File

@@ -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;
},

View File

@@ -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',

View File

@@ -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());
};

View File

@@ -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([]);
});
});

View File

@@ -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",

View File

@@ -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"