mirror of
https://github.com/solidtime-io/solidtime.git
synced 2026-10-09 14:23:19 +01:00
136 lines
5.0 KiB
TypeScript
136 lines
5.0 KiB
TypeScript
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'),
|
||
};
|
||
}
|