Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 86 additions & 0 deletions src/lib/utilities/schedule-spec-label.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -616,6 +616,92 @@ describe('getScheduleSpecLabel', () => {
).toBe('Every weekday at 8:30 AM UTC');
});

it('should handle stepped calendar specs as intervals', () => {
expect(
getScheduleSpecLabel({
calendar: [
{
second: '0',
minute: '0-59/15',
hour: '0-23',
dayOfMonth: '1-31',
month: '1-12',
dayOfWeek: '0-6',
},
],
}),
).toBe('Every 15 minutes');
});

it('should handle stepped structured calendar specs as intervals', () => {
expect(
getScheduleSpecLabel({
structuredCalendar: [
{
second: [{ start: 0 }],
minute: [{ start: 0, end: 59, step: 15 }],
hour: [{ start: 0, end: 23, step: 1 }],
dayOfMonth: [{ start: 1, end: 31, step: 1 }],
month: [{ start: 1, end: 12, step: 1 }],
dayOfWeek: [{ start: 0, end: 6, step: 1 }],
},
],
}),
).toBe('Every 15 minutes');
});

it('should handle direct stepped calendar specs as intervals', () => {
expect(
getScheduleSpecLabel({
second: '0',
minute: '0-59/15',
hour: '0-23',
dayOfMonth: '1-31',
month: '1-12',
dayOfWeek: '0-6',
}),
).toBe('Every 15 minutes');
});

it('should handle minute step shorthand as intervals', () => {
expect(
getScheduleSpecLabel({
second: '0',
minute: '*/20',
hour: '*',
dayOfMonth: '*',
month: '*',
dayOfWeek: '*',
}),
).toBe('Every 20 minutes');
});

it('should handle stepped day-of-week calendar specs', () => {
expect(
getScheduleSpecLabel({
second: '0',
minute: '0',
hour: '9',
dayOfMonth: '*',
month: '*',
dayOfWeek: '1-5/2',
}),
).toBe('Every Monday, Wednesday, Friday at 9:00 AM UTC');
});

it('should handle stepped day-of-month calendar specs', () => {
expect(
getScheduleSpecLabel({
second: '0',
minute: '0',
hour: '9',
dayOfMonth: '1-31/10',
month: '*',
dayOfWeek: '*',
}),
).toBe('on the 1st, 11th, 21st, 31st at 9:00 AM UTC');
});

it('should dispatch to interval spec', () => {
expect(
getScheduleSpecLabel({
Expand Down
261 changes: 255 additions & 6 deletions src/lib/utilities/schedule-spec-label.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import type {
CalendarSpec,
IntervalSpec,
RangeSpec,
StructuredCalendarSpec,
Expand Down Expand Up @@ -42,6 +43,47 @@ const MONTH_NAMES = [
'December',
];

type CalendarField =
| 'second'
| 'minute'
| 'hour'
| 'dayOfMonth'
| 'month'
| 'year'
| 'dayOfWeek';

type CalendarFieldConfig = {
defaultExpression: string;
max: number;
min: number;
};

type ScheduleSpecLabelInput = Partial<CalendarSpec> & {
calendar?: CalendarSpec[] | null;
interval?: IntervalSpec[] | null;
structuredCalendar?: StructuredCalendarSpec[] | null;
};

const CALENDAR_FIELDS: CalendarField[] = [
'second',
'minute',
'hour',
'dayOfMonth',
'month',
'year',
'dayOfWeek',
];

const CALENDAR_FIELD_CONFIG: Record<CalendarField, CalendarFieldConfig> = {
second: { min: 0, max: 59, defaultExpression: '0' },
minute: { min: 0, max: 59, defaultExpression: '0' },
hour: { min: 0, max: 23, defaultExpression: '0' },
dayOfMonth: { min: 1, max: 31, defaultExpression: '*' },
month: { min: 1, max: 12, defaultExpression: '*' },
year: { min: 0, max: 9999, defaultExpression: '*' },
dayOfWeek: { min: 0, max: 6, defaultExpression: '*' },
};

function expandRange(range: RangeSpec): number[] {
const start = range.start ?? 0;
const end = range.end ?? start;
Expand Down Expand Up @@ -111,6 +153,90 @@ function isAllDaysOfMonth(dayOfMonth: RangeSpec[] | undefined | null): boolean {
return isFullRange(values, 1, 31);
}

function isAllYears(year: RangeSpec[] | undefined | null): boolean {
return !year || year.length === 0;
}

function isFullField(
ranges: RangeSpec[] | undefined | null,
min: number,
max: number,
): boolean {
const values = expandRanges(ranges);
if (values.length === 0) return false;

return isFullRange(values, min, max);
}

function isSingleValue(
ranges: RangeSpec[] | undefined | null,
value: number,
): boolean {
const values = expandRanges(ranges);
return values.length === 0 || (values.length === 1 && values[0] === value);
}

function getFullFieldStep(
ranges: RangeSpec[] | undefined | null,
min: number,
max: number,
): number | null {
if (!ranges || ranges.length !== 1) return null;

const range = ranges[0];
const start = range.start ?? min;
const end = range.end ?? start;
const step = range.step && range.step > 0 ? range.step : 1;

if (step <= 1 || start !== min || end !== max) return null;

return step;
}

function formatIntervalLabel(seconds: number): string {
const label = formatDuration(`${seconds}s`);
return label ? `Every ${label}` : '';
}

function getCalendarIntervalLabel(spec: StructuredCalendarSpec): string {
const allDates =
isAllDays(spec.dayOfWeek) &&
isAllDaysOfMonth(spec.dayOfMonth) &&
isAllMonths(spec.month) &&
isAllYears(spec.year);

if (!allDates) return '';

const secondStep = getFullFieldStep(spec.second, 0, 59);
if (
secondStep &&
isFullField(spec.minute, 0, 59) &&
isFullField(spec.hour, 0, 23)
) {
return formatIntervalLabel(secondStep);
}

const minuteStep = getFullFieldStep(spec.minute, 0, 59);
if (
minuteStep &&
isSingleValue(spec.second, 0) &&
isFullField(spec.hour, 0, 23)
) {
return formatIntervalLabel(minuteStep * 60);
}

const hourStep = getFullFieldStep(spec.hour, 0, 23);
if (
hourStep &&
isSingleValue(spec.second, 0) &&
isSingleValue(spec.minute, 0)
) {
return formatIntervalLabel(hourStep * 60 * 60);
}

return '';
}

function formatHour12(hour: number): { hour: number; period: string } {
if (hour === 0) return { hour: 12, period: 'AM' };
if (hour < 12) return { hour, period: 'AM' };
Expand Down Expand Up @@ -157,6 +283,109 @@ function ordinal(n: number): string {
return n + (s[(v - 20) % 10] || s[v] || s[0]);
}

function parseNamedCalendarValue(value: string, field: CalendarField): number {
const normalized = value.toLowerCase();

if (field === 'month') {
const monthIndex = MONTH_NAMES.findIndex(
(name) =>
name.toLowerCase() === normalized ||
name.toLowerCase().slice(0, 3) === normalized,
);

return monthIndex > 0 ? monthIndex : Number.NaN;
}

if (field === 'dayOfWeek') {
if (normalized === '7') return 0;

const dayIndex = DAY_NAMES.findIndex(
(name) =>
name.toLowerCase() === normalized ||
name.toLowerCase().slice(0, 3) === normalized,
);

return dayIndex >= 0 ? dayIndex : Number.NaN;
}

return Number.NaN;
}

function parseCalendarValue(value: string, field: CalendarField): number {
const parsed = Number.parseInt(value, 10);
if (!Number.isNaN(parsed)) return parsed;

return parseNamedCalendarValue(value, field);
}

function parseCalendarRangePart(
part: string,
field: CalendarField,
): RangeSpec | null {
const [rangeExpression, stepExpression] = part.split('/');
const config = CALENDAR_FIELD_CONFIG[field];
const step = stepExpression ? Number.parseInt(stepExpression, 10) : 1;

if (!Number.isFinite(step) || step < 1) return null;

if (rangeExpression === '*') {
return { start: config.min, end: config.max, step };
}

if (rangeExpression.includes('-')) {
const [startExpression, endExpression] = rangeExpression.split('-');
const start = parseCalendarValue(startExpression, field);
const end = parseCalendarValue(endExpression, field);

if (Number.isNaN(start) || Number.isNaN(end)) return null;

return { start, end, step };
}

const start = parseCalendarValue(rangeExpression, field);
if (Number.isNaN(start)) return null;

return {
start,
end: step > 1 ? config.max : start,
step,
};
}

function parseCalendarField(
expression: string | null | undefined,
field: CalendarField,
): RangeSpec[] | undefined {
const config = CALENDAR_FIELD_CONFIG[field];
const value = expression?.trim() || config.defaultExpression;

if (field === 'year' && value === '*') return undefined;

return value
.split(',')
.map((part) => parseCalendarRangePart(part.trim(), field))
.filter((range): range is RangeSpec => range !== null);
}

function toStructuredCalendarSpec(spec: CalendarSpec): StructuredCalendarSpec {
return {
comment: spec.comment,
second: parseCalendarField(spec.second, 'second'),
minute: parseCalendarField(spec.minute, 'minute'),
hour: parseCalendarField(spec.hour, 'hour'),
dayOfMonth: parseCalendarField(spec.dayOfMonth, 'dayOfMonth'),
month: parseCalendarField(spec.month, 'month'),
year: parseCalendarField(spec.year, 'year'),
dayOfWeek: parseCalendarField(spec.dayOfWeek, 'dayOfWeek'),
};
}

function hasCalendarSpecFields(
spec: ScheduleSpecLabelInput,
): spec is CalendarSpec {
return CALENDAR_FIELDS.some((field) => typeof spec[field] === 'string');
}

function getSingleCalendarLabel(
spec: StructuredCalendarSpec,
timezone: string,
Expand Down Expand Up @@ -256,17 +485,23 @@ export function getCalendarSpecLabel(
}

if (specs.length === 1) {
return getSingleCalendarLabel(specs[0], timezone);
return (
getCalendarIntervalLabel(specs[0]) ||
getSingleCalendarLabel(specs[0], timezone)
);
}

return specs.map((spec) => getSingleCalendarLabel(spec, timezone)).join('; ');
return specs
.map(
(spec) =>
getCalendarIntervalLabel(spec) ||
getSingleCalendarLabel(spec, timezone),
)
.join('; ');
}

export function getScheduleSpecLabel(
spec: {
structuredCalendar?: StructuredCalendarSpec[];
interval?: IntervalSpec[];
},
spec: ScheduleSpecLabelInput,
timezone = 'UTC',
): string {
const parts: string[] = [];
Expand All @@ -276,6 +511,20 @@ export function getScheduleSpecLabel(
if (label) parts.push(label);
}

if (spec.calendar && spec.calendar.length > 0) {
const label = getCalendarSpecLabel(
spec.calendar.map(toStructuredCalendarSpec),
timezone,
);
if (label) parts.push(label);
} else if (hasCalendarSpecFields(spec)) {
const label = getCalendarSpecLabel(
[toStructuredCalendarSpec(spec)],
timezone,
);
if (label) parts.push(label);
}

if (spec.interval && spec.interval.length > 0) {
for (const interval of spec.interval) {
const label = getIntervalLabel(interval);
Expand Down
Loading