diff --git a/e2e/calendar.spec.ts b/e2e/calendar.spec.ts index 52046666..d554e7d5 100644 --- a/e2e/calendar.spec.ts +++ b/e2e/calendar.spec.ts @@ -252,19 +252,19 @@ test('test that calendar page loads and displays time entries', async ({ page, c test('test that calendar navigation buttons work', async ({ page }) => { await goToCalendar(page); await expect(page.locator('.fc')).toBeVisible(); + await expect(page.getByTestId('calendar-title')).toContainText('This Week'); - // Click the "next" button to navigate forward - await page.getByRole('button', { name: 'Next' }).click(); - await expect(page.locator('.fc')).toBeVisible(); - - // Click the "prev" button to navigate back + // Navigate between named periods. await page.getByRole('button', { name: 'Previous' }).click(); - await expect(page.locator('.fc')).toBeVisible(); - - // Navigate forward first, then click today + await expect(page.getByTestId('calendar-title')).toContainText('Last Week'); await page.getByRole('button', { name: 'Next' }).click(); + await expect(page.getByTestId('calendar-title')).toContainText('This Week'); + + // Navigate forward first, then use the range button to return to today. + await page.getByRole('button', { name: 'Next' }).click(); + await expect(page.getByTestId('calendar-title')).toContainText('Next Week'); await page.getByRole('button', { name: 'today' }).click(); - await expect(page.locator('.fc')).toBeVisible(); + await expect(page.getByTestId('calendar-title')).toContainText('This Week'); }); test('test that editing time entry description via calendar modal works', async ({ page, ctx }) => { diff --git a/e2e/reporting.spec.ts b/e2e/reporting.spec.ts index f19ded9e..caac459e 100644 --- a/e2e/reporting.spec.ts +++ b/e2e/reporting.spec.ts @@ -18,6 +18,25 @@ import { // Each test registers a new user and creates test data via API test.describe.configure({ timeout: 30000 }); +test('date range picker labels and navigates reporting periods', async ({ page }) => { + await goToReporting(page); + + const range = page.getByTestId('date_range_picker_display'); + const previous = page.getByTestId('date_range_picker_previous'); + const next = page.getByTestId('date_range_picker_next'); + + await expect(range).toContainText('Last 14 Days'); + await expect(next).toBeDisabled(); + + await Promise.all([waitForReportingUpdate(page), previous.click()]); + await expect(range).not.toContainText('Last 14 Days'); + + // The initial reporting range is still fresh in Vue Query's cache, so returning to it + // does not necessarily trigger another aggregate request. + await next.click(); + await expect(range).toContainText('Last 14 Days'); +}); + // ────────────────────────────────────────────────── // Project Multiselect Dropdown Tests // ────────────────────────────────────────────────── diff --git a/e2e/timesheet.spec.ts b/e2e/timesheet.spec.ts index eaa64acf..f857f8da 100644 --- a/e2e/timesheet.spec.ts +++ b/e2e/timesheet.spec.ts @@ -409,20 +409,20 @@ test('navigating to previous week shows entries from that week', async ({ page, test('can navigate forward and return to current week', async ({ page }) => { await Promise.all([goToTimesheet(page), waitForTimesheetLoad(page)]); - // Should show "This week" - await expect(page.getByTestId('timesheet_week_display')).toContainText('This week'); + // Should show "This Week" + await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); // Go to next week — the text assertions below auto-retry until the // header label flips. await page.getByTestId('timesheet_next_week').click(); - // Should no longer show "This week" - await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This week'); + // Should no longer show "This Week" + await expect(page.getByTestId('timesheet_week_display')).not.toContainText('This Week'); // Go back to this week await page.getByTestId('timesheet_week_display').click(); - await expect(page.getByTestId('timesheet_week_display')).toContainText('This week'); + await expect(page.getByTestId('timesheet_week_display')).toContainText('This Week'); }); // ────────────────────────────────────────────────── diff --git a/resources/js/Components/Common/Reporting/ReportingOverview.vue b/resources/js/Components/Common/Reporting/ReportingOverview.vue index 444dd716..4a1b0af1 100644 --- a/resources/js/Components/Common/Reporting/ReportingOverview.vue +++ b/resources/js/Components/Common/Reporting/ReportingOverview.vue @@ -57,7 +57,7 @@ const { handleApiRequestNotifications } = useNotificationsStore(); const startDate = useSessionStorage( 'reporting-start-date', - getLocalizedDayJs(getDayJsInstance()().format()).subtract(14, 'd').format() + getLocalizedDayJs(getDayJsInstance()().format()).subtract(13, 'd').format() ); const endDate = useSessionStorage( 'reporting-end-date', diff --git a/resources/js/Components/Timesheet/TimesheetHeader.vue b/resources/js/Components/Timesheet/TimesheetHeader.vue index 8ce79740..4172b12a 100644 --- a/resources/js/Components/Timesheet/TimesheetHeader.vue +++ b/resources/js/Components/Timesheet/TimesheetHeader.vue @@ -1,11 +1,9 @@ + + diff --git a/resources/js/packages/ui/src/Input/DateRangePicker.vue b/resources/js/packages/ui/src/Input/DateRangePicker.vue index b9190f2f..80fdc0d5 100644 --- a/resources/js/packages/ui/src/Input/DateRangePicker.vue +++ b/resources/js/packages/ui/src/Input/DateRangePicker.vue @@ -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({ 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>('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) => { diff --git a/resources/js/packages/ui/src/index.ts b/resources/js/packages/ui/src/index.ts index bca99301..68e928e8 100644 --- a/resources/js/packages/ui/src/index.ts +++ b/resources/js/packages/ui/src/index.ts @@ -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, diff --git a/resources/js/packages/ui/src/utils/dateRange.test.ts b/resources/js/packages/ui/src/utils/dateRange.test.ts new file mode 100644 index 00000000..24db4212 --- /dev/null +++ b/resources/js/packages/ui/src/utils/dateRange.test.ts @@ -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'); + }); +}); diff --git a/resources/js/packages/ui/src/utils/dateRange.ts b/resources/js/packages/ui/src/utils/dateRange.ts new file mode 100644 index 00000000..72377c12 --- /dev/null +++ b/resources/js/packages/ui/src/utils/dateRange.ts @@ -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'), + }; +}