feat: Allow users to see heatmap for last 30 days (#10848)
<img width="989" alt="Screenshot 2025-02-05 at 6 34 12 PM" src="https://github.com/user-attachments/assets/ae811842-23f7-4bbc-8a42-7cbe4849d287" /> View heatmaps for last 30 days based on the filter.
This commit is contained in:
@@ -94,7 +94,8 @@ module Api::V2::Accounts::HeatmapHelper
|
|||||||
end
|
end
|
||||||
|
|
||||||
def since_timestamp(date)
|
def since_timestamp(date)
|
||||||
(date - 6.days).to_i.to_s
|
number_of_days = params[:days_before].present? ? params[:days_before].to_i.days : 6.days
|
||||||
|
(date - number_of_days).to_i.to_s
|
||||||
end
|
end
|
||||||
|
|
||||||
def until_timestamp(date)
|
def until_timestamp(date)
|
||||||
|
|||||||
@@ -61,9 +61,9 @@ class ReportsAPI extends ApiClient {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
getConversationTrafficCSV() {
|
getConversationTrafficCSV({ daysBefore = 6 } = {}) {
|
||||||
return axios.get(`${this.url}/conversation_traffic`, {
|
return axios.get(`${this.url}/conversation_traffic`, {
|
||||||
params: { timezone_offset: getTimeOffset() },
|
params: { timezone_offset: getTimeOffset(), days_before: daysBefore },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
28
app/javascript/dashboard/composables/useLiveRefresh.js
Normal file
28
app/javascript/dashboard/composables/useLiveRefresh.js
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { ref, onBeforeUnmount } from 'vue';
|
||||||
|
|
||||||
|
export const useLiveRefresh = (callback, interval = 60000) => {
|
||||||
|
const timeoutId = ref(null);
|
||||||
|
|
||||||
|
const startRefetching = () => {
|
||||||
|
timeoutId.value = setTimeout(async () => {
|
||||||
|
await callback();
|
||||||
|
startRefetching();
|
||||||
|
}, interval);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopRefetching = () => {
|
||||||
|
if (timeoutId.value) {
|
||||||
|
clearTimeout(timeoutId.value);
|
||||||
|
timeoutId.value = null;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
stopRefetching();
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
startRefetching,
|
||||||
|
stopRefetching,
|
||||||
|
};
|
||||||
|
};
|
||||||
@@ -3,13 +3,11 @@ import { mapGetters } from 'vuex';
|
|||||||
import AgentTable from './components/overview/AgentTable.vue';
|
import AgentTable from './components/overview/AgentTable.vue';
|
||||||
import MetricCard from './components/overview/MetricCard.vue';
|
import MetricCard from './components/overview/MetricCard.vue';
|
||||||
import { OVERVIEW_METRICS } from './constants';
|
import { OVERVIEW_METRICS } from './constants';
|
||||||
import ReportHeatmap from './components/Heatmap.vue';
|
|
||||||
|
|
||||||
import endOfDay from 'date-fns/endOfDay';
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
import getUnixTime from 'date-fns/getUnixTime';
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
import startOfDay from 'date-fns/startOfDay';
|
|
||||||
import subDays from 'date-fns/subDays';
|
|
||||||
import ReportHeader from './components/ReportHeader.vue';
|
import ReportHeader from './components/ReportHeader.vue';
|
||||||
|
import HeatmapContainer from './components/HeatmapContainer.vue';
|
||||||
export const FETCH_INTERVAL = 60000;
|
export const FETCH_INTERVAL = 60000;
|
||||||
|
|
||||||
export default {
|
export default {
|
||||||
@@ -18,7 +16,7 @@ export default {
|
|||||||
ReportHeader,
|
ReportHeader,
|
||||||
AgentTable,
|
AgentTable,
|
||||||
MetricCard,
|
MetricCard,
|
||||||
ReportHeatmap,
|
HeatmapContainer,
|
||||||
},
|
},
|
||||||
data() {
|
data() {
|
||||||
return {
|
return {
|
||||||
@@ -33,7 +31,6 @@ export default {
|
|||||||
agents: 'agents/getAgents',
|
agents: 'agents/getAgents',
|
||||||
accountConversationMetric: 'getAccountConversationMetric',
|
accountConversationMetric: 'getAccountConversationMetric',
|
||||||
agentConversationMetric: 'getAgentConversationMetric',
|
agentConversationMetric: 'getAgentConversationMetric',
|
||||||
accountConversationHeatmap: 'getAccountConversationHeatmapData',
|
|
||||||
uiFlags: 'getOverviewUIFlags',
|
uiFlags: 'getOverviewUIFlags',
|
||||||
}),
|
}),
|
||||||
agentStatusMetrics() {
|
agentStatusMetrics() {
|
||||||
@@ -80,7 +77,6 @@ export default {
|
|||||||
fetchAllData() {
|
fetchAllData() {
|
||||||
this.fetchAccountConversationMetric();
|
this.fetchAccountConversationMetric();
|
||||||
this.fetchAgentConversationMetric();
|
this.fetchAgentConversationMetric();
|
||||||
this.fetchHeatmapData();
|
|
||||||
},
|
},
|
||||||
downloadHeatmapData() {
|
downloadHeatmapData() {
|
||||||
let to = endOfDay(new Date());
|
let to = endOfDay(new Date());
|
||||||
@@ -89,33 +85,7 @@ export default {
|
|||||||
to: getUnixTime(to),
|
to: getUnixTime(to),
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
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() {
|
fetchAccountConversationMetric() {
|
||||||
this.$store.dispatch('fetchAccountConversationMetric', {
|
this.$store.dispatch('fetchAccountConversationMetric', {
|
||||||
type: 'account',
|
type: 'account',
|
||||||
@@ -180,25 +150,7 @@ export default {
|
|||||||
</MetricCard>
|
</MetricCard>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="flex flex-row flex-wrap max-w-full">
|
<HeatmapContainer />
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
|
||||||
<template #control>
|
|
||||||
<woot-button
|
|
||||||
icon="arrow-download"
|
|
||||||
size="small"
|
|
||||||
variant="smooth"
|
|
||||||
color-scheme="secondary"
|
|
||||||
@click="downloadHeatmapData"
|
|
||||||
>
|
|
||||||
{{ $t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT') }}
|
|
||||||
</woot-button>
|
|
||||||
</template>
|
|
||||||
<ReportHeatmap
|
|
||||||
:heat-data="accountConversationHeatmap"
|
|
||||||
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
|
||||||
/>
|
|
||||||
</MetricCard>
|
|
||||||
</div>
|
|
||||||
<div class="flex flex-row flex-wrap max-w-full">
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
<MetricCard :header="$t('OVERVIEW_REPORTS.AGENT_CONVERSATIONS.HEADER')">
|
||||||
<AgentTable
|
<AgentTable
|
||||||
|
|||||||
@@ -10,10 +10,14 @@ import { groupHeatmapByDay } from 'helpers/ReportsDataHelper';
|
|||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
heatData: {
|
heatmapData: {
|
||||||
type: Array,
|
type: Array,
|
||||||
default: () => [],
|
default: () => [],
|
||||||
},
|
},
|
||||||
|
numberOfRows: {
|
||||||
|
type: Number,
|
||||||
|
default: 7,
|
||||||
|
},
|
||||||
isLoading: {
|
isLoading: {
|
||||||
type: Boolean,
|
type: Boolean,
|
||||||
default: false,
|
default: false,
|
||||||
@@ -21,11 +25,11 @@ const props = defineProps({
|
|||||||
});
|
});
|
||||||
const { t } = useI18n();
|
const { t } = useI18n();
|
||||||
const processedData = computed(() => {
|
const processedData = computed(() => {
|
||||||
return groupHeatmapByDay(props.heatData);
|
return groupHeatmapByDay(props.heatmapData);
|
||||||
});
|
});
|
||||||
|
|
||||||
const quantileRange = computed(() => {
|
const quantileRange = computed(() => {
|
||||||
const flattendedData = props.heatData.map(data => data.value);
|
const flattendedData = props.heatmapData.map(data => data.value);
|
||||||
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
return getQuantileIntervals(flattendedData, [0.2, 0.4, 0.6, 0.8, 0.9, 0.99]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -95,14 +99,14 @@ function getHeatmapLevelClass(value) {
|
|||||||
<template v-if="isLoading">
|
<template v-if="isLoading">
|
||||||
<div class="grid gap-[5px] flex-shrink-0">
|
<div class="grid gap-[5px] flex-shrink-0">
|
||||||
<div
|
<div
|
||||||
v-for="ii in 7"
|
v-for="ii in numberOfRows"
|
||||||
:key="ii"
|
:key="ii"
|
||||||
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
class="w-full rounded-sm bg-slate-100 dark:bg-slate-900 animate-loader-pulse h-8 min-w-[70px]"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div class="grid gap-[5px] w-full min-w-[700px]">
|
<div class="grid gap-[5px] w-full min-w-[700px]">
|
||||||
<div
|
<div
|
||||||
v-for="ii in 7"
|
v-for="ii in numberOfRows"
|
||||||
:key="ii"
|
:key="ii"
|
||||||
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
class="grid gap-[5px] grid-cols-[repeat(24,_1fr)]"
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup>
|
||||||
|
import { onMounted, ref, computed } from 'vue';
|
||||||
|
import { useToggle } from '@vueuse/core';
|
||||||
|
import MetricCard from './overview/MetricCard.vue';
|
||||||
|
import ReportHeatmap from './Heatmap.vue';
|
||||||
|
import { useStore, useMapGetter } from 'dashboard/composables/store';
|
||||||
|
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
|
||||||
|
import endOfDay from 'date-fns/endOfDay';
|
||||||
|
import getUnixTime from 'date-fns/getUnixTime';
|
||||||
|
import startOfDay from 'date-fns/startOfDay';
|
||||||
|
import subDays from 'date-fns/subDays';
|
||||||
|
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
|
||||||
|
import Button from 'dashboard/components-next/button/Button.vue';
|
||||||
|
import { useI18n } from 'vue-i18n';
|
||||||
|
|
||||||
|
const store = useStore();
|
||||||
|
|
||||||
|
const uiFlags = useMapGetter('getOverviewUIFlags');
|
||||||
|
const accountConversationHeatmap = useMapGetter(
|
||||||
|
'getAccountConversationHeatmapData'
|
||||||
|
);
|
||||||
|
const { t } = useI18n();
|
||||||
|
|
||||||
|
const menuItems = [
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
|
||||||
|
value: 6,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
|
||||||
|
value: 29,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const selectedDays = ref(6);
|
||||||
|
|
||||||
|
const selectedDayFilter = computed(() =>
|
||||||
|
menuItems.find(menuItem => menuItem.value === selectedDays.value)
|
||||||
|
);
|
||||||
|
|
||||||
|
const downloadHeatmapData = () => {
|
||||||
|
const to = endOfDay(new Date());
|
||||||
|
store.dispatch('downloadAccountConversationHeatmap', {
|
||||||
|
daysBefore: selectedDays.value,
|
||||||
|
to: getUnixTime(to),
|
||||||
|
});
|
||||||
|
};
|
||||||
|
const [showDropdown, toggleDropdown] = useToggle();
|
||||||
|
const fetchHeatmapData = () => {
|
||||||
|
if (uiFlags.value.isFetchingAccountConversationsHeatmap) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let to = endOfDay(new Date());
|
||||||
|
let from = startOfDay(subDays(to, Number(selectedDays.value)));
|
||||||
|
|
||||||
|
store.dispatch('fetchAccountConversationHeatmap', {
|
||||||
|
metric: 'conversations_count',
|
||||||
|
from: getUnixTime(from),
|
||||||
|
to: getUnixTime(to),
|
||||||
|
groupBy: 'hour',
|
||||||
|
businessHours: false,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleAction = ({ value }) => {
|
||||||
|
toggleDropdown(false);
|
||||||
|
selectedDays.value = value;
|
||||||
|
fetchHeatmapData();
|
||||||
|
};
|
||||||
|
|
||||||
|
const { startRefetching } = useLiveRefresh(fetchHeatmapData);
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
fetchHeatmapData();
|
||||||
|
startRefetching();
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div class="flex flex-row flex-wrap max-w-full">
|
||||||
|
<MetricCard :header="$t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.HEADER')">
|
||||||
|
<template #control>
|
||||||
|
<div
|
||||||
|
v-on-clickaway="() => toggleDropdown(false)"
|
||||||
|
class="relative flex items-center group"
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="selectedDayFilter.label"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="toggleDropdown()"
|
||||||
|
/>
|
||||||
|
<DropdownMenu
|
||||||
|
v-if="showDropdown"
|
||||||
|
:menu-items="menuItems"
|
||||||
|
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
|
||||||
|
@action="handleAction($event)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
sm
|
||||||
|
slate
|
||||||
|
faded
|
||||||
|
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
|
||||||
|
class="rounded-md group-hover:bg-n-alpha-2"
|
||||||
|
@click="downloadHeatmapData"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<ReportHeatmap
|
||||||
|
:heatmap-data="accountConversationHeatmap"
|
||||||
|
:number-of-rows="selectedDays + 1"
|
||||||
|
:is-loading="uiFlags.isFetchingAccountConversationsHeatmap"
|
||||||
|
/>
|
||||||
|
</MetricCard>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
@@ -1,26 +1,20 @@
|
|||||||
<script>
|
<script setup>
|
||||||
import Spinner from 'shared/components/Spinner.vue';
|
import Spinner from 'shared/components/Spinner.vue';
|
||||||
|
|
||||||
export default {
|
defineProps({
|
||||||
name: 'MetricCard',
|
header: {
|
||||||
components: {
|
type: String,
|
||||||
Spinner,
|
default: '',
|
||||||
},
|
},
|
||||||
props: {
|
isLoading: {
|
||||||
header: {
|
type: Boolean,
|
||||||
type: String,
|
default: false,
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
isLoading: {
|
|
||||||
type: Boolean,
|
|
||||||
default: false,
|
|
||||||
},
|
|
||||||
loadingMessage: {
|
|
||||||
type: String,
|
|
||||||
default: '',
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
};
|
loadingMessage: {
|
||||||
|
type: String,
|
||||||
|
default: '',
|
||||||
|
},
|
||||||
|
});
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<template>
|
<template>
|
||||||
@@ -46,9 +40,7 @@ export default {
|
|||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div class="flex flex-row items-center justify-end gap-2">
|
||||||
class="transition-opacity duration-200 ease-in-out opacity-20 hover:opacity-100 flex flex-row items-center justify-end gap-2"
|
|
||||||
>
|
|
||||||
<slot name="control" />
|
<slot name="control" />
|
||||||
</div>
|
</div>
|
||||||
</slot>
|
</slot>
|
||||||
|
|||||||
@@ -4,10 +4,7 @@ import Report from '../../api/reports';
|
|||||||
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
import { downloadCsvFile, generateFileName } from '../../helper/downloadHelper';
|
||||||
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
import AnalyticsHelper from '../../helper/AnalyticsHelper';
|
||||||
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
import { REPORTS_EVENTS } from '../../helper/AnalyticsHelper/events';
|
||||||
import {
|
import { clampDataBetweenTimeline } from 'shared/helpers/ReportsDataHelper';
|
||||||
reconcileHeatmapData,
|
|
||||||
clampDataBetweenTimeline,
|
|
||||||
} from 'shared/helpers/ReportsDataHelper';
|
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
fetchingStatus: false,
|
fetchingStatus: false,
|
||||||
@@ -114,11 +111,6 @@ export const actions = {
|
|||||||
let { data } = heatmapData;
|
let { data } = heatmapData;
|
||||||
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
data = clampDataBetweenTimeline(data, reportObj.from, reportObj.to);
|
||||||
|
|
||||||
data = reconcileHeatmapData(
|
|
||||||
data,
|
|
||||||
state.overview.accountConversationHeatmap
|
|
||||||
);
|
|
||||||
|
|
||||||
commit(types.default.SET_HEATMAP_DATA, data);
|
commit(types.default.SET_HEATMAP_DATA, data);
|
||||||
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
commit(types.default.TOGGLE_HEATMAP_LOADING, false);
|
||||||
});
|
});
|
||||||
@@ -234,7 +226,7 @@ export const actions = {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
downloadAccountConversationHeatmap(_, reportObj) {
|
downloadAccountConversationHeatmap(_, reportObj) {
|
||||||
Report.getConversationTrafficCSV()
|
Report.getConversationTrafficCSV({ daysBefore: reportObj.daysBefore })
|
||||||
.then(response => {
|
.then(response => {
|
||||||
downloadCsvFile(
|
downloadCsvFile(
|
||||||
generateFileName({
|
generateFileName({
|
||||||
|
|||||||
Reference in New Issue
Block a user