add date range navigation, unify picker components

This commit is contained in:
Gregor Vostrak
2026-09-22 12:55:21 +02:00
parent a0262addb7
commit c23f09fb2d
15 changed files with 576 additions and 193 deletions

View File

@@ -1,13 +1,12 @@
<script setup lang="ts">
import { Button } from '..';
import { ChevronLeft, ChevronRight } from '@lucide/vue';
import { Tabs, TabsList } from '../tabs';
import TabBarItem from '../TabBar/TabBarItem.vue';
import DateRangeNavigator from '../Input/DateRangeNavigator.vue';
import CalendarSettingsPopover from './CalendarSettingsPopover.vue';
import type { CalendarSettings } from './calendarSettings';
defineProps<{
viewTitle: string;
rangeLabel: string;
activeView: string;
settings: CalendarSettings;
}>();
@@ -23,31 +22,13 @@ const emit = defineEmits<{
<template>
<div class="flex items-center justify-between bg-default-background px-2 py-1.5">
<!-- Left: Navigation -->
<div class="flex items-center gap-1">
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Previous"
@click="emit('prev')">
<ChevronLeft class="h-4 w-4" />
</Button>
<Button
variant="outline"
size="sm"
class="h-8 w-8 p-0"
aria-label="Next"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
<Button variant="outline" size="sm" @click="emit('today')"> today </Button>
</div>
<!-- Center: Title -->
<span data-testid="calendar-title" class="text-base font-semibold text-foreground">{{
viewTitle
}}</span>
<DateRangeNavigator
:label="rangeLabel"
trigger-test-id="calendar-title"
trigger-aria-label="today"
@previous="emit('prev')"
@next="emit('next')"
@select="emit('today')" />
<!-- Right: View switcher + Settings -->
<div class="flex items-center gap-1">

View File

@@ -138,7 +138,7 @@ const {
const {
activeView,
viewDays,
viewTitle,
rangeLabel,
emitDatesChange,
handlePrev,
handleNext,
@@ -514,7 +514,7 @@ function getEventDurationSeconds(dayEvent: DayEvent, dayStr: string): number {
<template v-if="!loading">
<CalendarToolbar
:view-title="viewTitle"
:range-label="rangeLabel"
:active-view="activeView"
:settings="calendarSettings"
@prev="handlePrev"

View File

@@ -2,6 +2,7 @@ import { computed, ref } from 'vue';
import type { Dayjs } from 'dayjs';
import { getLocalizedDayJs } from '../utils/time';
import { getWeekStartDayNumber } from '../utils/settings';
import { getDateRangeLabel } from '../utils/dateRange';
export function useCalendarNavigation(callbacks: {
onDatesChange: (payload: { start: Dayjs; end: Dayjs }) => void;
@@ -34,24 +35,10 @@ export function useCalendarNavigation(callbacks: {
return days;
});
const viewTitle = computed(() => {
if (activeView.value === 'timeGridDay') {
return currentDate.value.format('MMMM YYYY');
}
const rangeLabel = computed(() => {
const days = viewDays.value;
if (days.length === 0) return '';
const first = days[0]!;
const last = days[days.length - 1]!;
if (first.year() !== last.year()) {
return `${first.format('MMM YYYY')} \u2013 ${last.format('MMM YYYY')}`;
}
if (first.month() !== last.month()) {
return `${first.format('MMM')} \u2013 ${last.format('MMM YYYY')}`;
}
return first.format('MMMM YYYY');
return getDateRangeLabel(days[0]!, days[days.length - 1]!);
});
function emitDatesChange() {
@@ -99,7 +86,7 @@ export function useCalendarNavigation(callbacks: {
activeView,
currentDate,
viewDays,
viewTitle,
rangeLabel,
emitDatesChange,
handlePrev,
handleNext,

View File

@@ -0,0 +1,81 @@
<script setup lang="ts">
import { ChevronLeft, ChevronRight, CalendarIcon } from '@lucide/vue';
import { Button } from '../Buttons';
withDefaults(
defineProps<{
label: string;
secondaryLabel?: string;
previousDisabled?: boolean;
nextDisabled?: boolean;
previousTestId?: string;
triggerTestId?: string;
nextTestId?: string;
triggerAriaLabel?: string;
}>(),
{
secondaryLabel: undefined,
previousTestId: undefined,
triggerTestId: undefined,
nextTestId: undefined,
triggerAriaLabel: undefined,
}
);
const emit = defineEmits<{
previous: [];
next: [];
select: [];
}>();
const triggerClass =
'h-8 w-[13.5rem] justify-center rounded-none border-l border-r border-border-secondary px-2.5 text-text-primary tabular-nums hover:bg-card-background-active';
</script>
<template>
<div
class="tabular-nums inline-flex items-stretch overflow-hidden rounded-md border border-input bg-card-background shadow-xs dark:bg-transparent"
role="group"
aria-label="Date range navigation">
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Previous"
:disabled="previousDisabled"
:data-testid="previousTestId"
@click="emit('previous')">
<ChevronLeft class="h-4 w-4" />
</Button>
<slot name="trigger" :trigger-class="triggerClass">
<Button
type="button"
variant="ghost"
size="sm"
:class="triggerClass"
:aria-label="triggerAriaLabel"
:data-testid="triggerTestId"
@click="emit('select')">
<CalendarIcon class="h-4 w-4 shrink-0 text-icon-default" />
<span class="min-w-0 truncate">{{ label }}</span>
<span v-if="secondaryLabel" class="shrink-0 text-text-tertiary">
&middot; {{ secondaryLabel }}
</span>
</Button>
</slot>
<Button
type="button"
variant="ghost"
size="icon"
class="h-8 w-8 shrink-0 rounded-none hover:bg-card-background-active"
aria-label="Next"
:disabled="nextDisabled"
:data-testid="nextTestId"
@click="emit('next')">
<ChevronRight class="h-4 w-4" />
</Button>
</div>
</template>

View File

@@ -6,22 +6,22 @@ import { CalendarDate } from '@internationalized/date';
import { CalendarIcon } from '@lucide/vue';
import { computed, ref, inject, type ComputedRef, watch } from 'vue';
import { twMerge } from 'tailwind-merge';
import {
getDayJsInstance,
getLocalizedDayJs,
firstDayIndex,
type WeekStartDay,
} from '@/packages/ui/src/utils/time';
import DateRangeNavigator from './DateRangeNavigator.vue';
import { getLocalizedDayJs, firstDayIndex, type WeekStartDay } from '@/packages/ui/src/utils/time';
import { getDateRangeLabel, shiftDateRange, type DateRangeDirection } from '../utils/dateRange';
import { type Organization } from '@/packages/api/src';
import { getUserTimezone } from '@/packages/ui/src/utils/settings';
import { formatDate } from '@/packages/ui/src/utils/time';
const weekStartsOn = computed((): WeekStartDay => firstDayIndex.value as WeekStartDay);
const props = defineProps<{
start: string;
end: string;
}>();
const props = withDefaults(
defineProps<{
start: string;
end: string;
allowFuture?: boolean;
}>(),
{ allowFuture: false }
);
const emit = defineEmits<{
(e: 'update:start', value: string): void;
@@ -35,10 +35,13 @@ interface CalendarDateRange {
}
const today = computed(() => {
const now = getDayJsInstance()();
const now = getLocalizedDayJs();
return new CalendarDate(now.year(), now.month() + 1, now.date());
});
const startDay = computed(() => (props.start ? getLocalizedDayJs(props.start) : undefined));
const endDay = computed(() => (props.end ? getLocalizedDayJs(props.end) : undefined));
const modelValue = computed<CalendarDateRange>({
get: () => ({
start: props.start
@@ -91,7 +94,7 @@ function setLastWeek() {
}
function setLast14Days() {
emit('update:start', getLocalizedDayJs().subtract(14, 'days').format());
emit('update:start', getLocalizedDayJs().subtract(13, 'days').startOf('day').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
@@ -109,14 +112,14 @@ function setLastMonth() {
}
function setLast30Days() {
emit('update:start', getLocalizedDayJs().subtract(30, 'days').format());
emit('update:start', getLocalizedDayJs().subtract(29, 'days').startOf('day').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
function setLast90Days() {
emit('update:start', getDayJsInstance()().subtract(90, 'days').format());
emit('update:end', getDayJsInstance()().format());
emit('update:start', getLocalizedDayJs().subtract(89, 'days').startOf('day').format());
emit('update:end', getLocalizedDayJs().format());
open.value = false;
}
@@ -140,6 +143,38 @@ function setLastYear() {
const organization = inject<ComputedRef<Organization>>('organization');
const displayLabel = computed(() => {
if (!startDay.value) return 'Pick a date';
if (!endDay.value) {
return getDateRangeLabel(startDay.value, startDay.value, {
dateFormat: organization?.value?.date_format,
});
}
return getDateRangeLabel(startDay.value, endDay.value, {
dateFormat: organization?.value?.date_format,
});
});
const nextRange = computed(() => {
if (!startDay.value || !endDay.value) return undefined;
return shiftDateRange(startDay.value, endDay.value, 1);
});
const nextDisabled = computed(
() =>
!props.allowFuture &&
!!nextRange.value &&
nextRange.value.start.isAfter(getLocalizedDayJs().endOf('day'))
);
function navigate(direction: DateRangeDirection) {
if (!startDay.value || !endDay.value) return;
const shifted = shiftDateRange(startDay.value, endDay.value, direction);
emit('update:start', shifted.start.format());
emit('update:end', shifted.end.format());
emit('submit');
}
watch(open, (value) => {
if (value === false) {
emit('submit');
@@ -148,77 +183,120 @@ watch(open, (value) => {
</script>
<template>
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
variant="outline"
:class="
twMerge(
'flex w-full items-center justify-between whitespace-nowrap h-[34px] text-start',
!modelValue && 'text-muted-foreground'
)
">
<CalendarIcon class="-ml-0.5 text-text-quaternary h-4 w-4" />
<template v-if="modelValue.start">
<template v-if="modelValue.end">
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
-
{{ formatDate(modelValue.end.toString(), organization?.date_format) }}
</template>
<template v-else>
{{ formatDate(modelValue.start.toString(), organization?.date_format) }}
</template>
</template>
<template v-else> Pick a date </template>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button variant="ghost" size="sm" class="justify-start" @click="setToday"
>Today</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisWeek"
>This Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastWeek"
>Last Week</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast14Days"
>Last 14 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisMonth"
>This Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastMonth"
>Last Month</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast30Days"
>Last 30 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast90Days"
>Last 90 days</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLast12Months"
>Last 12 months</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setThisYear"
>This year</Button
>
<Button variant="ghost" size="sm" class="justify-start" @click="setLastYear"
>Last year</Button
>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
<DateRangeNavigator
:label="displayLabel"
:next-disabled="nextDisabled"
previous-test-id="date_range_picker_previous"
trigger-test-id="date_range_picker_display"
next-test-id="date_range_picker_next"
@previous="navigate(-1)"
@next="navigate(1)">
<template #trigger="{ triggerClass }">
<Popover v-model:open="open">
<PopoverTrigger as-child>
<Button
type="button"
variant="ghost"
size="sm"
:class="twMerge(triggerClass, !modelValue.start && 'text-muted-foreground')"
data-testid="date_range_picker_display">
<CalendarIcon class="text-text-quaternary h-4 w-4 shrink-0" />
<span class="min-w-0 truncate">{{ displayLabel }}</span>
</Button>
</PopoverTrigger>
<PopoverContent class="w-auto p-0">
<div class="flex divide-x divide-border-secondary">
<div
class="text-text-primary text-sm flex flex-col space-y-0.5 items-start py-2 px-2">
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setToday"
>Today</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisWeek"
>This Week</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastWeek"
>Last Week</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast14Days"
>Last 14 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisMonth"
>This Month</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastMonth"
>Last Month</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast30Days"
>Last 30 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast90Days"
>Last 90 Days</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLast12Months"
>Last 12 Months</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setThisYear"
>This Year</Button
>
<Button
variant="ghost"
size="sm"
class="justify-start"
@click="setLastYear"
>Last Year</Button
>
</div>
<div class="pl-2">
<RangeCalendar
v-model="modelValue"
initial-focus
:number-of-months="2"
:max-value="allowFuture ? undefined : today"
:week-starts-on="weekStartsOn" />
</div>
</div>
</PopoverContent>
</Popover>
</template>
</DateRangeNavigator>
</template>

View File

@@ -132,6 +132,7 @@ import FullCalendarDayHeader from './FullCalendar/FullCalendarDayHeader.vue';
import FullCalendarEventContent from './FullCalendar/FullCalendarEventContent.vue';
import TimeEntryCalendar from './FullCalendar/TimeEntryCalendar.vue';
import DateRangePicker from './Input/DateRangePicker.vue';
import DateRangeNavigator from './Input/DateRangeNavigator.vue';
import { Label } from './label/index';
import {
NumberField,
@@ -211,6 +212,7 @@ export {
ContextMenuSubTrigger,
ContextMenuTrigger,
DateRangePicker,
DateRangeNavigator,
Dialog,
DialogClose,
DialogContent,

View File

@@ -0,0 +1,124 @@
import dayjs from 'dayjs';
import { afterEach, describe, expect, test, vi } from 'vitest';
import { getDayJsInstance, getLocalizedDayJs } from './time';
import { getDateRangeLabel, shiftDateRange } from './dateRange';
const reference = dayjs('2026-09-21');
afterEach(() => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('UTC');
});
describe('getDateRangeLabel', () => {
test('labels relative weeks', () => {
getDayJsInstance();
expect(
getDateRangeLabel(reference.startOf('week'), reference.endOf('week'), {
referenceDate: reference,
})
).toBe('This Week');
expect(
getDateRangeLabel(
reference.subtract(1, 'week').startOf('week'),
reference.subtract(1, 'week').endOf('week'),
{ referenceDate: reference }
)
).toBe('Last Week');
});
test('labels common rolling ranges', () => {
expect(
getDateRangeLabel(reference.subtract(13, 'day'), reference.endOf('day'), {
referenceDate: reference,
})
).toBe('Last 14 Days');
});
test('labels full calendar months and years', () => {
expect(
getDateRangeLabel(dayjs('2025-09-01'), dayjs('2025-09-30'), {
referenceDate: reference,
})
).toBe('September 2025');
expect(
getDateRangeLabel(dayjs('2024-01-01'), dayjs('2024-12-31'), {
referenceDate: reference,
})
).toBe('2024');
});
test('keeps relative labels for the current periods', () => {
expect(
getDateRangeLabel(reference.startOf('month'), reference.endOf('month'), {
referenceDate: reference,
})
).toBe('This Month');
expect(
getDateRangeLabel(reference.startOf('year'), reference.endOf('year'), {
referenceDate: reference,
})
).toBe('This Year');
});
test('uses the organization date format for custom ranges', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-12'), {
dateFormat: 'slash-separated-dd-mm-yyyy',
referenceDate: reference,
})
).toBe('03/08/2026 – 12/08/2026');
});
test('renders a single historical day without duplicating it', () => {
expect(
getDateRangeLabel(dayjs('2026-08-03'), dayjs('2026-08-03'), {
referenceDate: reference,
})
).toBe('Aug 3, 2026');
});
});
describe('shiftDateRange', () => {
test('moves full weeks as calendar periods', () => {
getDayJsInstance();
const shifted = shiftDateRange(reference.startOf('week'), reference.endOf('week'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-09-14');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-09-20');
});
test('moves full months without truncating longer months', () => {
const shifted = shiftDateRange(dayjs('2026-02-01'), dayjs('2026-02-28'), 1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-03-01');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-03-31');
});
test('moves custom ranges by their inclusive duration', () => {
const shifted = shiftDateRange(dayjs('2026-09-01'), dayjs('2026-09-14'), -1);
expect(shifted.start.format('YYYY-MM-DD')).toBe('2026-08-18');
expect(shifted.end.format('YYYY-MM-DD')).toBe('2026-08-31');
});
test('normalizes custom range offsets across the spring DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-03-14T23:00:00Z'),
getLocalizedDayJs('2026-03-27T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-03-29T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-04-11T23:59:59+02:00');
});
test('normalizes custom range offsets across the autumn DST transition', () => {
vi.mocked(window.getTimezoneSetting).mockReturnValue('Europe/Vienna');
const shifted = shiftDateRange(
getLocalizedDayJs('2026-10-17T22:00:00Z'),
getLocalizedDayJs('2026-10-30T23:00:00Z'),
1
);
expect(shifted.start.format()).toBe('2026-11-01T00:00:00+01:00');
expect(shifted.end.format()).toBe('2026-11-14T23:59:59+01:00');
});
});

View File

@@ -0,0 +1,135 @@
import type { Dayjs } from 'dayjs';
import { formatDate, getDayJsInstance, getLocalizedDayJs, type DateFormat } from './time';
export type DateRangeDirection = -1 | 1;
function isSameDay(left: Dayjs, right: Dayjs): boolean {
return left.isSame(right, 'day');
}
function isRange(start: Dayjs, end: Dayjs, expectedStart: Dayjs, expectedEnd: Dayjs): boolean {
return isSameDay(start, expectedStart) && isSameDay(end, expectedEnd);
}
function formatCompactRange(start: Dayjs, end: Dayjs): string {
if (start.year() !== end.year()) {
return `${start.format('MMM D, YYYY')} – ${end.format('MMM D, YYYY')}`;
}
if (start.month() !== end.month()) {
return `${start.format('MMM D')} – ${end.format('MMM D, YYYY')}`;
}
return `${start.format('MMM D')} – ${end.format('D, YYYY')}`;
}
/**
* Returns a human-friendly label for an inclusive date range. Relative labels
* are intentionally shared by reporting, the timesheet, and the calendar.
*/
export function getDateRangeLabel(
start: Dayjs,
end: Dayjs,
options: { dateFormat?: DateFormat; referenceDate?: Dayjs } = {}
): string {
// Ensure the configured first day of the week is applied before using startOf('week').
getDayJsInstance();
const reference = (options.referenceDate ?? getLocalizedDayJs()).startOf('day');
const rangeStart = start.startOf('day');
const rangeEnd = end.startOf('day');
const relativeDays: Array<[number, string]> = [
[0, 'Today'],
[-1, 'Yesterday'],
[1, 'Tomorrow'],
];
for (const [offset, label] of relativeDays) {
const day = reference.add(offset, 'day');
if (isRange(rangeStart, rangeEnd, day, day)) return label;
}
const relativePeriods: Array<['week' | 'month' | 'year', number, string]> = [
['week', 0, 'This Week'],
['week', -1, 'Last Week'],
['week', 1, 'Next Week'],
['month', 0, 'This Month'],
['month', -1, 'Last Month'],
['month', 1, 'Next Month'],
['year', 0, 'This Year'],
['year', -1, 'Last Year'],
['year', 1, 'Next Year'],
];
for (const [unit, offset, label] of relativePeriods) {
const period = reference.add(offset, unit);
if (isRange(rangeStart, rangeEnd, period.startOf(unit), period.endOf(unit))) return label;
}
// Named calendar periods outside the relative windows above, e.g. "May 2025"
// for a full month and "2025" for a full year.
const monthStart = rangeStart.startOf('month');
if (isRange(rangeStart, rangeEnd, monthStart, monthStart.endOf('month'))) {
return rangeStart.format('MMMM YYYY');
}
const yearStart = rangeStart.startOf('year');
if (isRange(rangeStart, rangeEnd, yearStart, yearStart.endOf('year'))) {
return rangeStart.format('YYYY');
}
const rollingRanges: Array<[number, string]> = [
[14, 'Last 14 Days'],
[30, 'Last 30 Days'],
[90, 'Last 90 Days'],
];
for (const [days, label] of rollingRanges) {
if (isRange(rangeStart, rangeEnd, reference.subtract(days - 1, 'day'), reference)) {
return label;
}
}
if (isRange(rangeStart, rangeEnd, reference.subtract(12, 'month'), reference)) {
return 'Last 12 Months';
}
if (isSameDay(rangeStart, rangeEnd)) {
return options.dateFormat
? formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)
: rangeStart.format('MMM D, YYYY');
}
if (options.dateFormat) {
return `${formatDate(rangeStart.format('YYYY-MM-DD'), options.dateFormat)} – ${formatDate(
rangeEnd.format('YYYY-MM-DD'),
options.dateFormat
)}`;
}
return formatCompactRange(rangeStart, rangeEnd);
}
/** Shift an inclusive range by one period, preserving full calendar periods. */
export function shiftDateRange(
start: Dayjs,
end: Dayjs,
direction: DateRangeDirection
): { start: Dayjs; end: Dayjs } {
getDayJsInstance();
const rangeStart = start.startOf('day');
const rangeEnd = end.endOf('day');
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('year'), rangeStart.endOf('year'))) {
const shiftedStart = rangeStart.add(direction, 'year').startOf('year');
return { start: shiftedStart, end: shiftedStart.endOf('year') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('month'), rangeStart.endOf('month'))) {
const shiftedStart = rangeStart.add(direction, 'month').startOf('month');
return { start: shiftedStart, end: shiftedStart.endOf('month') };
}
if (isRange(rangeStart, rangeEnd, rangeStart.startOf('week'), rangeStart.endOf('week'))) {
const shiftedStart = rangeStart.add(direction, 'week').startOf('week');
return { start: shiftedStart, end: shiftedStart.endOf('week') };
}
const days = rangeEnd.startOf('day').diff(rangeStart, 'day') + 1;
return {
start: rangeStart.add(days * direction, 'day').startOf('day'),
end: rangeEnd.add(days * direction, 'day').endOf('day'),
};
}