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

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