Skip to content
Open
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
11 changes: 8 additions & 3 deletions readme.md
Original file line number Diff line number Diff line change
Expand Up @@ -297,11 +297,16 @@ import {
- `options: { timezone?, skipRepeatedHour?, missingHour?, strict? }` (optional)
- `timezone: CronosTimezone | string | number` (optional)
Timezone in which to schedule the tasks, can be either a `CronosTimezone` object, or any IANA timezone or offset accepted by the [`CronosTimezone` constructor](#cronostimezone)
- `skipRepeatedHour: boolean` (optional)
- `skipRepeatedHour: boolean` (optional, default `true`)
Should tasks be scheduled in the repeated hour when DST ends. [Further details](#skiprepeatedhour-option)
- `missingHour: 'insert' | 'offset' | 'skip'` (optional)
- `missingHour: 'insert' | 'offset' | 'skip'` (optional, default `insert`)
How tasks should be scheduled in the missing hour when DST starts. [Further details](#missinghour-option)
- `strict: boolean | {<WarningType>: boolean, ...}` (optional)
- `intersectDayOfFields: boolean` (optional, default `false`)
By default, when both the 'day of month' and 'day of week' fields are set, days that match *either* of the fields will be selected. But if this option is `true`, then only days that match *both* fields will be selected.
eg. for the expression `0 0 0 13 * fri *`, from the date 8th Aug 2025, the following dates will match:
- `intersectDayOfFields: false` (days that are the 13th *or* a friday): Fri 8th Aug, Wed 13th Aug, Fri 15th Aug, Fri 22nd Aug, Fri 29th Aug, etc...
- `intersectDayOfFields: true` (days that are the 13th *and* a friday): Fri 13th Feb 2026, Fri 13th Mar 2026, Fri 13th Nov 2026, Fri 13th Aug 2027, Fri 13th Oct 2028, etc...
- `strict: boolean | {<WarningType>: boolean, ...}` (optional, default `false`)
Should an error be thrown if warnings occur during parsing. If `true`, will throw for all `WarningType`'s, alternatively an object can be provided with `WarningType`'s as the keys and boolean values to individually select which `WarningType`'s trigger an error to be thown. `WarningTypes`'s are listed in the [`CronosExpression.warnings`](#cronosexpression) documentation.

- **Returns** [`CronosTask`](#cronostask)
Expand Down
12 changes: 11 additions & 1 deletion src/expression.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ export class CronosExpression implements DateSequence {
private timezone: CronosTimezone | undefined;
private skipRepeatedHour = true;
private missingHour: "insert" | "offset" | "skip" = "insert";
private intersectDayOfFields = false;
private _warnings: Warning[] | null = null;

private constructor(
Expand All @@ -35,6 +36,7 @@ export class CronosExpression implements DateSequence {
timezone?: string | number | CronosTimezone;
skipRepeatedHour?: boolean;
missingHour?: CronosExpression["missingHour"];
intersectDayOfFields?: boolean;
strict?: boolean | { [key in WarningType]?: boolean } | undefined;
} = {}
) {
Expand Down Expand Up @@ -78,6 +80,10 @@ export class CronosExpression implements DateSequence {
? options.skipRepeatedHour
: expr.skipRepeatedHour;
expr.missingHour = options.missingHour ?? expr.missingHour;
expr.intersectDayOfFields =
options.intersectDayOfFields !== undefined
? options.intersectDayOfFields
: expr.intersectDayOfFields;

return expr;
}
Expand Down Expand Up @@ -243,7 +249,11 @@ export class CronosExpression implements DateSequence {
}

private _nextDay(fromDate: CronosDate): CronosDate | null {
const days = this.days.getDays(fromDate.year, fromDate.month);
const days = this.days.getDays(
fromDate.year,
fromDate.month,
this.intersectDayOfFields
);

let nextDayIndex = findFirstFrom(fromDate.day, days);

Expand Down
34 changes: 24 additions & 10 deletions src/parser.ts
Original file line number Diff line number Diff line change
Expand Up @@ -391,8 +391,8 @@ export class DaysFieldValues {
return values;
}

getDays(year: number, month: number): number[] {
const days: Set<number> = new Set(this.days);
getDays(year: number, month: number, intersect: boolean): number[] {
const weekdays: Set<number> = new Set();

const lastDateOfMonth = new Date(year, month, 0).getDate();
const firstDayOfWeek = new Date(year, month - 1, 1).getDay();
Expand All @@ -404,14 +404,11 @@ export class DaysFieldValues {
return weekday + (weekday < 1 ? 3 : weekday > lastDateOfMonth ? -3 : 0);
};

if (this.lastDay) {
days.add(lastDateOfMonth);
}
if (this.lastWeekday) {
days.add(getNearestWeekday(lastDateOfMonth));
weekdays.add(getNearestWeekday(lastDateOfMonth));
}
for (const day of this.nearestWeekday) {
days.add(getNearestWeekday(day));
weekdays.add(getNearestWeekday(day));
}

if (
Expand All @@ -428,19 +425,36 @@ export class DaysFieldValues {

for (const dayOfWeek of this.daysOfWeek) {
for (const day of daysOfWeek[dayOfWeek]!) {
days.add(day);
weekdays.add(day);
}
}
for (const dayOfWeek of this.lastDaysOfWeek) {
for (let i = daysOfWeek[dayOfWeek]!.length - 1; i >= 0; i--) {
if (daysOfWeek[dayOfWeek]![i]! <= lastDateOfMonth) {
days.add(daysOfWeek[dayOfWeek]![i]!);
weekdays.add(daysOfWeek[dayOfWeek]![i]!);
break;
}
}
}
for (const [dayOfWeek, nthOfMonth] of this.nthDaysOfWeek) {
days.add(daysOfWeek[dayOfWeek]![nthOfMonth - 1]!);
weekdays.add(daysOfWeek[dayOfWeek]![nthOfMonth - 1]!);
}
}

const days = new Set(this.days);
if (this.lastDay) {
days.add(lastDateOfMonth);
}

if (intersect) {
for (const d of days) {
if (!weekdays.has(d)) {
days.delete(d);
}
}
} else {
for (const d of weekdays) {
days.add(d);
}
}

Expand Down
48 changes: 47 additions & 1 deletion tests/simple.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { test, expect } from "vitest";
import { test, expect, describe } from "vitest";
import { CronosExpression } from "../src/index.js";

test("Every minute (* * * * *)", () => {
Expand Down Expand Up @@ -250,3 +250,49 @@ test("1st of the month at 00:00, every 3rd month from aug to apr (0 0 1 aug-apr/
new Date(2020, 10, 1, 0, 0, 0),
]);
});

describe("Midday friday and / or the 13th of the month (0 0 12 13 * fri *)", () => {
const expr = "0 0 12 13 * fri *";

test("intersectDayOfFields: true", () => {
expect(
CronosExpression.parse(expr, {
intersectDayOfFields: true,
}).nextNDates(
new Date(2025, 7, 8), // August 8, 2025
10
)
).toEqual([
new Date(2026, 1, 13, 12), // Fri 13th Feb 2026
new Date(2026, 2, 13, 12), // Fri 13th Mar 2026
new Date(2026, 10, 13, 12), // Fri 13th Nov 2026
new Date(2027, 7, 13, 12), // Fri 13th Aug 2027
new Date(2028, 9, 13, 12), // Fri 13th Oct 2028
new Date(2029, 3, 13, 12), // Fri 13th Apr 2029
new Date(2029, 6, 13, 12), // Fri 13th Jul 2029
new Date(2030, 8, 13, 12), // Fri 13th Sep 2030
new Date(2030, 11, 13, 12), // Fri 13th Dec 2030
new Date(2031, 5, 13, 12), // Fri 13th Jun 2031
]);
});

test("intersectDayOfFields: false (default)", () => {
expect(
CronosExpression.parse(expr).nextNDates(
new Date(2025, 7, 8), // August 8, 2025
10
)
).toEqual([
new Date(2025, 7, 8, 12), // Fri 8th Aug 2025
new Date(2025, 7, 13, 12), // Wed 13th Aug 2025
new Date(2025, 7, 15, 12), // Fri 15th Aug 2025
new Date(2025, 7, 22, 12), // Fri 22nd Aug 2025
new Date(2025, 7, 29, 12), // Fri 29th Aug 2025
new Date(2025, 8, 5, 12), // Fri 5th Sep 2025
new Date(2025, 8, 12, 12), // Fri 12th Sep 2025
new Date(2025, 8, 13, 12), // Sat 13th Sep 2025
new Date(2025, 8, 19, 12), // Fri 19th Sep 2025
new Date(2025, 8, 26, 12), // Fri 26th Sep 2025
]);
});
});