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:
@@ -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>
|
||||
Reference in New Issue
Block a user