mirror of
https://github.com/lingble/chatwoot.git
synced 2025-10-29 18:22:53 +00:00
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:
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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', {
|
||||
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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',
|
||||
|
||||
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([]);
|
||||
});
|
||||
});
|
||||
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
|
||||
Reference in New Issue
Block a user