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:
110
app/javascript/shared/helpers/ReportsDataHelper.js
Normal file
110
app/javascript/shared/helpers/ReportsDataHelper.js
Normal 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());
|
||||
};
|
||||
204
app/javascript/shared/helpers/specs/ReportsDataHelper.spec.js
Normal file
204
app/javascript/shared/helpers/specs/ReportsDataHelper.spec.js
Normal 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([]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user