mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-13 16:23:18 +01:00
add date range navigation, unify picker components
This commit is contained in:
124
resources/js/packages/ui/src/utils/dateRange.test.ts
Normal file
124
resources/js/packages/ui/src/utils/dateRange.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
135
resources/js/packages/ui/src/utils/dateRange.ts
Normal file
135
resources/js/packages/ui/src/utils/dateRange.ts
Normal 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'),
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user