feat: allow selecting month range in overview reports (#12701)

Co-authored-by: Sivin Varghese <64252451+iamsivin@users.noreply.github.com>
Co-authored-by: iamsivin <iamsivin@gmail.com>
This commit is contained in:
Shivam Mishra
2025-11-12 18:34:00 +05:30
committed by GitHub
parent fb0be60ae2
commit 28e16f7ee0
5 changed files with 484 additions and 108 deletions

View File

@@ -8,11 +8,15 @@ import Avatar from 'dashboard/components-next/avatar/Avatar.vue';
const props = defineProps({ const props = defineProps({
menuItems: { menuItems: {
type: Array, type: Array,
required: true, default: () => [],
validator: value => { validator: value => {
return value.every(item => item.action && item.value && item.label); return value.every(item => item.action && item.value && item.label);
}, },
}, },
menuSections: {
type: Array,
default: () => [],
},
thumbnailSize: { thumbnailSize: {
type: Number, type: Number,
default: 20, default: 20,
@@ -42,19 +46,62 @@ const { t } = useI18n();
const searchInput = ref(null); const searchInput = ref(null);
const searchQuery = ref(''); const searchQuery = ref('');
const filteredMenuItems = computed(() => { const hasSections = computed(() => props.menuSections.length > 0);
if (!searchQuery.value) return props.menuItems;
return props.menuItems.filter(item => const flattenedMenuItems = computed(() => {
if (!hasSections.value) {
return props.menuItems;
}
return props.menuSections.flatMap(section => section.items || []);
});
const filteredMenuItems = computed(() => {
if (!searchQuery.value) return flattenedMenuItems.value;
return flattenedMenuItems.value.filter(item =>
item.label.toLowerCase().includes(searchQuery.value.toLowerCase()) item.label.toLowerCase().includes(searchQuery.value.toLowerCase())
); );
}); });
const filteredMenuSections = computed(() => {
if (!hasSections.value) {
return [];
}
if (!searchQuery.value) {
return props.menuSections;
}
const query = searchQuery.value.toLowerCase();
return props.menuSections
.map(section => {
const filteredItems = (section.items || []).filter(item =>
item.label.toLowerCase().includes(query)
);
return {
...section,
items: filteredItems,
};
})
.filter(section => section.items.length > 0);
});
const handleAction = item => { const handleAction = item => {
const { action, value, ...rest } = item; const { action, value, ...rest } = item;
emit('action', { action, value, ...rest }); emit('action', { action, value, ...rest });
}; };
const shouldShowEmptyState = computed(() => {
if (hasSections.value) {
return filteredMenuSections.value.length === 0;
}
return filteredMenuItems.value.length === 0;
});
onMounted(() => { onMounted(() => {
if (searchInput.value && props.showSearch) { if (searchInput.value && props.showSearch) {
searchInput.value.focus(); searchInput.value.focus();
@@ -64,9 +111,16 @@ onMounted(() => {
<template> <template>
<div <div
class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 py-2 px-2 gap-2 flex flex-col min-w-[136px] shadow-lg" class="bg-n-alpha-3 backdrop-blur-[100px] border-0 outline outline-1 outline-n-container absolute rounded-xl z-50 gap-2 flex flex-col min-w-[136px] shadow-lg pb-2 px-2"
:class="{
'pt-2': !showSearch,
}"
> >
<div v-if="showSearch" class="relative"> <div
v-if="showSearch"
class="sticky top-0 bg-n-alpha-3 backdrop-blur-sm pt-2"
>
<div class="relative">
<span class="absolute i-lucide-search size-3.5 top-2 left-3" /> <span class="absolute i-lucide-search size-3.5 top-2 left-3" />
<input <input
ref="searchInput" ref="searchInput"
@@ -78,6 +132,62 @@ onMounted(() => {
class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12" class="reset-base w-full h-8 py-2 pl-10 pr-2 text-sm focus:outline-none border-none rounded-lg bg-n-alpha-black2 dark:bg-n-solid-1 text-n-slate-12"
/> />
</div> </div>
</div>
<template v-if="hasSections">
<div
v-for="(section, sectionIndex) in filteredMenuSections"
:key="section.title || sectionIndex"
class="flex flex-col gap-1"
>
<p
v-if="section.title"
class="px-2 pt-2 text-xs font-medium text-n-slate-11 uppercase tracking-wide"
>
{{ section.title }}
</p>
<button
v-for="(item, itemIndex) in section.items"
:key="item.value || itemIndex"
type="button"
class="inline-flex items-center justify-start w-full h-8 min-w-0 gap-2 px-2 py-1.5 transition-all duration-200 ease-in-out border-0 rounded-lg z-60 hover:bg-n-alpha-1 dark:hover:bg-n-alpha-2 disabled:cursor-not-allowed disabled:pointer-events-none disabled:opacity-50"
:class="{
'bg-n-alpha-1 dark:bg-n-solid-active': item.isSelected,
'text-n-ruby-11': item.action === 'delete',
'text-n-slate-12': item.action !== 'delete',
}"
:disabled="item.disabled"
@click="handleAction(item)"
>
<slot name="thumbnail" :item="item">
<Avatar
v-if="item.thumbnail"
:name="item.thumbnail.name"
:src="item.thumbnail.src"
:size="thumbnailSize"
rounded-full
/>
</slot>
<Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span
v-if="item.label"
class="min-w-0 text-sm truncate"
:class="labelClass"
>
{{ item.label }}
</span>
</button>
<div
v-if="sectionIndex < filteredMenuSections.length - 1"
class="h-px bg-n-alpha-2 mx-2 my-1"
/>
</div>
</template>
<template v-else>
<button <button
v-for="(item, index) in filteredMenuItems" v-for="(item, index) in filteredMenuItems"
:key="index" :key="index"
@@ -100,7 +210,11 @@ onMounted(() => {
rounded-full rounded-full
/> />
</slot> </slot>
<Icon v-if="item.icon" :icon="item.icon" class="flex-shrink-0 size-3.5" /> <Icon
v-if="item.icon"
:icon="item.icon"
class="flex-shrink-0 size-3.5"
/>
<span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span> <span v-if="item.emoji" class="flex-shrink-0">{{ item.emoji }}</span>
<span <span
v-if="item.label" v-if="item.label"
@@ -110,8 +224,9 @@ onMounted(() => {
{{ item.label }} {{ item.label }}
</span> </span>
</button> </button>
</template>
<div <div
v-if="filteredMenuItems.length === 0" v-if="shouldShowEmptyState"
class="text-sm text-n-slate-11 px-2 py-1.5" class="text-sm text-n-slate-11 px-2 py-1.5"
> >
{{ {{

View File

@@ -53,6 +53,8 @@
"LAST_7_DAYS": "Last 7 days", "LAST_7_DAYS": "Last 7 days",
"LAST_14_DAYS": "Last 14 days", "LAST_14_DAYS": "Last 14 days",
"LAST_30_DAYS": "Last 30 days", "LAST_30_DAYS": "Last 30 days",
"THIS_MONTH": "This month",
"LAST_MONTH": "Last month",
"LAST_3_MONTHS": "Last 3 months", "LAST_3_MONTHS": "Last 3 months",
"LAST_6_MONTHS": "Last 6 months", "LAST_6_MONTHS": "Last 6 months",
"LAST_YEAR": "Last year", "LAST_YEAR": "Last year",

View File

@@ -119,7 +119,7 @@ const tooltip = useHeatmapTooltip();
<!-- eslint-disable vue/no-static-inline-styles --> <!-- eslint-disable vue/no-static-inline-styles -->
<template> <template>
<div <div
class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr] min-h-72" class="grid relative w-full gap-x-4 gap-y-2.5 overflow-y-scroll md:overflow-visible grid-cols-[80px_1fr]"
> >
<template v-if="isLoading"> <template v-if="isLoading">
<div class="grid gap-[5px] flex-shrink-0"> <div class="grid gap-[5px] flex-shrink-0">

View File

@@ -1,15 +1,18 @@
<script setup> <script setup>
import { onMounted, ref, computed } from 'vue'; import { onMounted, ref, computed, watch } from 'vue';
import { useToggle } from '@vueuse/core'; import { useToggle } from '@vueuse/core';
import MetricCard from '../overview/MetricCard.vue'; import MetricCard from '../overview/MetricCard.vue';
import BaseHeatmap from './BaseHeatmap.vue'; import BaseHeatmap from './BaseHeatmap.vue';
import HeatmapDateRangeSelector from './HeatmapDateRangeSelector.vue';
import { useStore, useMapGetter } from 'dashboard/composables/store'; import { useStore, useMapGetter } from 'dashboard/composables/store';
import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh'; import { useLiveRefresh } from 'dashboard/composables/useLiveRefresh';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import endOfDay from 'date-fns/endOfDay'; import endOfDay from 'date-fns/endOfDay';
import format from 'date-fns/format';
import getUnixTime from 'date-fns/getUnixTime'; import getUnixTime from 'date-fns/getUnixTime';
import startOfDay from 'date-fns/startOfDay'; import startOfDay from 'date-fns/startOfDay';
import startOfMonth from 'date-fns/startOfMonth';
import subDays from 'date-fns/subDays'; import subDays from 'date-fns/subDays';
import format from 'date-fns/format';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue'; import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
import Button from 'dashboard/components-next/button/Button.vue'; import Button from 'dashboard/components-next/button/Button.vue';
import { useI18n } from 'vue-i18n'; import { useI18n } from 'vue-i18n';
@@ -57,27 +60,33 @@ const uiFlags = useMapGetter('getOverviewUIFlags');
const heatmapData = useMapGetter(props.storeGetter); const heatmapData = useMapGetter(props.storeGetter);
const inboxes = useMapGetter('inboxes/getInboxes'); const inboxes = useMapGetter('inboxes/getInboxes');
const menuItems = [ const selectedFrom = ref(null);
{ const selectedTo = ref(null);
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'), const selectedDaysBefore = ref(null);
value: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
value: 13,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 29,
},
];
const selectedDays = ref(6);
const selectedInbox = ref(null); const selectedInbox = ref(null);
const isMonthFilter = ref(false);
const currentMonthOffset = ref(0);
const selectedDayFilter = computed(() => const selectedRange = computed(() => {
menuItems.find(menuItem => menuItem.value === selectedDays.value) if (!selectedFrom.value || !selectedTo.value) {
); return null;
}
return {
from: selectedFrom.value,
to: selectedTo.value,
};
});
const numberOfRows = computed(() => {
if (!selectedRange.value) {
return 0;
}
const dateDifference = differenceInCalendarDays(
selectedRange.value.to,
selectedRange.value.from
);
return dateDifference + 1;
});
const inboxMenuItems = computed(() => { const inboxMenuItems = computed(() => {
return [ return [
@@ -105,13 +114,42 @@ const selectedInboxFilter = computed(() => {
const isLoading = computed(() => uiFlags.value[props.uiFlagKey]); const isLoading = computed(() => uiFlags.value[props.uiFlagKey]);
const downloadHeatmapData = () => { // Keeps relative presets (last 7 days / this month) aligned with "now" during live refreshes.
const resolveActiveRange = () => {
if (isMonthFilter.value && currentMonthOffset.value === 0) {
const now = new Date();
const monthStart = startOfMonth(now);
return {
from: startOfDay(monthStart),
to: endOfDay(now),
};
}
if (!isMonthFilter.value && selectedDaysBefore.value !== null) {
const to = endOfDay(new Date()); const to = endOfDay(new Date());
return {
from: startOfDay(subDays(to, Number(selectedDaysBefore.value))),
to,
};
}
return selectedRange.value;
};
const downloadHeatmapData = () => {
const range = resolveActiveRange();
if (!range) {
return;
}
const { to } = range;
const shouldUseBackendDownload =
!isMonthFilter.value && !selectedInbox.value && props.downloadAction;
// If no inbox is selected and download action exists, use backend endpoint // If no inbox is selected and download action exists, use backend endpoint
if (!selectedInbox.value && props.downloadAction) { if (shouldUseBackendDownload) {
store.dispatch(props.downloadAction, { store.dispatch(props.downloadAction, {
daysBefore: selectedDays.value, daysBefore: selectedDaysBefore.value,
to: getUnixTime(to), to: getUnixTime(to),
}); });
return; return;
@@ -150,7 +188,6 @@ const downloadHeatmapData = () => {
downloadCsvFile(fileName, csvContent); downloadCsvFile(fileName, csvContent);
}; };
const [showDropdown, toggleDropdown] = useToggle();
const [showInboxDropdown, toggleInboxDropdown] = useToggle(); const [showInboxDropdown, toggleInboxDropdown] = useToggle();
const fetchHeatmapData = () => { const fetchHeatmapData = () => {
@@ -158,8 +195,12 @@ const fetchHeatmapData = () => {
return; return;
} }
let to = endOfDay(new Date()); const range = resolveActiveRange();
let from = startOfDay(subDays(to, Number(selectedDays.value))); if (!range) {
return;
}
const { from, to } = range;
const params = { const params = {
metric: props.metric, metric: props.metric,
@@ -178,25 +219,43 @@ const fetchHeatmapData = () => {
store.dispatch(props.storeAction, params); store.dispatch(props.storeAction, params);
}; };
const handleAction = ({ value }) => {
toggleDropdown(false);
selectedDays.value = value;
fetchHeatmapData();
};
const handleInboxAction = ({ value }) => { const handleInboxAction = ({ value }) => {
toggleInboxDropdown(false); toggleInboxDropdown(false);
selectedInbox.value = value selectedInbox.value = value
? inboxes.value.find(inbox => inbox.id === value) ? inboxes.value.find(inbox => inbox.id === value)
: null; : null;
fetchHeatmapData();
}; };
const { startRefetching } = useLiveRefresh(fetchHeatmapData); const { startRefetching } = useLiveRefresh(fetchHeatmapData);
const handleRangeTypeChange = type => {
isMonthFilter.value = type === 'month';
};
const handleMonthOffsetChange = offset => {
currentMonthOffset.value = offset;
};
watch(
() => [selectedFrom.value, selectedTo.value],
([from, to]) => {
if (from && to) {
fetchHeatmapData();
}
}
);
watch(
() => selectedInbox.value,
() => {
if (selectedRange.value) {
fetchHeatmapData();
}
}
);
onMounted(() => { onMounted(() => {
store.dispatch('inboxes/get'); store.dispatch('inboxes/get');
fetchHeatmapData();
startRefetching(); startRefetching();
}); });
</script> </script>
@@ -205,25 +264,13 @@ onMounted(() => {
<div class="flex flex-row flex-wrap max-w-full"> <div class="flex flex-row flex-wrap max-w-full">
<MetricCard :header="title"> <MetricCard :header="title">
<template #control> <template #control>
<div <HeatmapDateRangeSelector
v-on-clickaway="() => toggleDropdown(false)" v-model:from="selectedFrom"
class="relative flex items-center group" v-model:to="selectedTo"
> v-model:days-num="selectedDaysBefore"
<Button @range-type-change="handleRangeTypeChange"
sm @month-offset-change="handleMonthOffsetChange"
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>
<div <div
v-on-clickaway="() => toggleInboxDropdown(false)" v-on-clickaway="() => toggleInboxDropdown(false)"
class="relative flex items-center group" class="relative flex items-center group"
@@ -241,22 +288,23 @@ onMounted(() => {
:menu-items="inboxMenuItems" :menu-items="inboxMenuItems"
show-search show-search
:search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')" :search-placeholder="t('INBOX_REPORTS.SEARCH_INBOX')"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full min-w-[200px]" class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full !min-w-56 max-w-56 max-h-96 overflow-y-auto"
@action="handleInboxAction($event)" @action="handleInboxAction($event)"
/> />
</div> </div>
<Button <Button
v-tooltip="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')"
sm sm
slate slate
faded faded
:label="t('OVERVIEW_REPORTS.CONVERSATION_HEATMAP.DOWNLOAD_REPORT')" icon="i-lucide-download"
class="rounded-md group-hover:bg-n-alpha-2" class="rounded-md group-hover:bg-n-alpha-2"
@click="downloadHeatmapData" @click="downloadHeatmapData"
/> />
</template> </template>
<BaseHeatmap <BaseHeatmap
:heatmap-data="heatmapData" :heatmap-data="heatmapData"
:number-of-rows="selectedDays + 1" :number-of-rows="numberOfRows"
:is-loading="isLoading" :is-loading="isLoading"
:color-scheme="colorScheme" :color-scheme="colorScheme"
/> />

View File

@@ -0,0 +1,211 @@
<script setup>
import { computed, ref, watch, defineModel } from 'vue';
import { useToggle } from '@vueuse/core';
import { useI18n } from 'vue-i18n';
import addMonths from 'date-fns/addMonths';
import differenceInCalendarDays from 'date-fns/differenceInCalendarDays';
import endOfDay from 'date-fns/endOfDay';
import endOfMonth from 'date-fns/endOfMonth';
import startOfDay from 'date-fns/startOfDay';
import startOfMonth from 'date-fns/startOfMonth';
import subDays from 'date-fns/subDays';
import { vOnClickOutside } from '@vueuse/components';
import Button from 'dashboard/components-next/button/Button.vue';
import DropdownMenu from 'dashboard/components-next/dropdown-menu/DropdownMenu.vue';
const emit = defineEmits(['rangeTypeChange', 'monthOffsetChange']);
const fromModel = defineModel('from', { type: Date, default: null });
const toModel = defineModel('to', { type: Date, default: null });
const daysNumModel = defineModel('daysNum', { type: Number, default: null });
const { t, locale } = useI18n();
const DATE_FILTER_TYPES = {
DAY: 'day',
MONTH: 'month',
};
const DATE_FILTER_ACTION = 'select_date_range';
const dayMenuItemConfigs = computed(() => [
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_7_DAYS'),
value: 'last_7_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 6,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_14_DAYS'),
value: 'last_14_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 13,
},
{
label: t('REPORT.DATE_RANGE_OPTIONS.LAST_30_DAYS'),
value: 'last_30_days',
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.DAY,
daysBefore: 29,
},
]);
const resolvedLocale = computed(
() =>
locale.value ||
(typeof navigator !== 'undefined' ? navigator.language : 'en')
);
const monthFormatter = computed(
() =>
new Intl.DateTimeFormat(resolvedLocale.value, {
month: 'long',
year: 'numeric',
})
);
const monthMenuItemConfigs = computed(() => {
const now = new Date();
const offsets = [0, -1, -2];
return offsets.map(offset => ({
label:
offset === 0
? t('REPORT.DATE_RANGE_OPTIONS.THIS_MONTH')
: monthFormatter.value.format(addMonths(now, offset)),
value: offset === 0 ? 'this_month' : `month_${offset}`,
action: DATE_FILTER_ACTION,
type: DATE_FILTER_TYPES.MONTH,
monthOffset: offset,
}));
});
const selectedDateRangeValue = ref('');
const [showDropdown, toggleDropdown] = useToggle();
const monthOffset = ref(0);
const menuItems = computed(() => {
const selectedValue = selectedDateRangeValue.value;
return [...dayMenuItemConfigs.value, ...monthMenuItemConfigs.value].map(
config => ({
...config,
isSelected: selectedValue === config.value,
})
);
});
selectedDateRangeValue.value = menuItems.value[0]?.value || '';
const menuSections = computed(() => {
const dayItems = menuItems.value.filter(
item => item.type === DATE_FILTER_TYPES.DAY
);
const monthItems = menuItems.value.filter(
item => item.type === DATE_FILTER_TYPES.MONTH
);
return [{ items: dayItems }, { items: monthItems }].filter(
section => section.items.length > 0
);
});
const selectedConfig = computed(
() =>
menuItems.value.find(
menuItem => menuItem.value === selectedDateRangeValue.value
) || menuItems.value[0]
);
const selectedLabel = computed(() => {
const selectedItem = menuItems.value.find(
item => item.value === selectedDateRangeValue.value
);
return selectedItem?.label || '';
});
const computeRange = config => {
if (!config) {
return null;
}
if (config.type === DATE_FILTER_TYPES.MONTH) {
const now = new Date();
const baseMonthStart = startOfMonth(addMonths(now, monthOffset.value));
const from = startOfDay(baseMonthStart);
const isCurrentMonth =
config.value === 'this_month' && monthOffset.value === 0;
const to = isCurrentMonth
? endOfDay(now)
: endOfDay(endOfMonth(baseMonthStart));
const daysBefore = differenceInCalendarDays(to, from);
return { from, to, daysBefore };
}
const to = endOfDay(new Date());
const from = startOfDay(subDays(to, Number(config.daysBefore)));
return { from, to, daysBefore: Number(config.daysBefore) };
};
const applySelection = config => {
if (!config) return;
if (config.type === DATE_FILTER_TYPES.MONTH) {
monthOffset.value = config.monthOffset || 0;
} else {
monthOffset.value = 0;
}
const range = computeRange(config);
if (!range) return;
const { from, to, daysBefore } = range;
fromModel.value = from;
toModel.value = to;
daysNumModel.value = daysBefore;
emit('rangeTypeChange', config.type);
emit('monthOffsetChange', monthOffset.value);
};
const handleAction = ({ action, value }) => {
toggleDropdown(false);
if (action !== DATE_FILTER_ACTION) {
return;
}
selectedDateRangeValue.value = value;
};
watch(
() => selectedConfig.value,
config => {
applySelection(config);
},
{ immediate: true }
);
</script>
<template>
<div
v-on-click-outside="() => toggleDropdown(false)"
class="relative flex items-center group"
>
<Button
sm
slate
faded
:label="selectedLabel"
class="rounded-md group-hover:bg-n-alpha-2"
@click="toggleDropdown()"
/>
<DropdownMenu
v-if="showDropdown"
:menu-items="menuItems"
:menu-sections="menuSections"
class="mt-1 ltr:right-0 rtl:left-0 xl:ltr:right-0 xl:rtl:left-0 top-full"
@action="handleAction($event)"
/>
</div>
</template>