From 0009bbcefafdf72d655be8b9cab4003320ca6bf1 Mon Sep 17 00:00:00 2001 From: James Clarke Date: Sun, 10 Aug 2025 17:01:38 +0100 Subject: [PATCH] Add `intersectDayOfFields` option --- readme.md | 11 +++++++--- src/expression.ts | 12 ++++++++++- src/parser.ts | 34 ++++++++++++++++++++++--------- tests/simple.test.ts | 48 +++++++++++++++++++++++++++++++++++++++++++- 4 files changed, 90 insertions(+), 15 deletions(-) diff --git a/readme.md b/readme.md index f3bfe35..ecb5cf0 100644 --- a/readme.md +++ b/readme.md @@ -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 | {: 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 | {: 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) diff --git a/src/expression.ts b/src/expression.ts index 3c92fd0..98ad425 100644 --- a/src/expression.ts +++ b/src/expression.ts @@ -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( @@ -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; } = {} ) { @@ -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; } @@ -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); diff --git a/src/parser.ts b/src/parser.ts index 7679aaf..e3bbea2 100644 --- a/src/parser.ts +++ b/src/parser.ts @@ -391,8 +391,8 @@ export class DaysFieldValues { return values; } - getDays(year: number, month: number): number[] { - const days: Set = new Set(this.days); + getDays(year: number, month: number, intersect: boolean): number[] { + const weekdays: Set = new Set(); const lastDateOfMonth = new Date(year, month, 0).getDate(); const firstDayOfWeek = new Date(year, month - 1, 1).getDay(); @@ -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 ( @@ -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); } } diff --git a/tests/simple.test.ts b/tests/simple.test.ts index 7dccd92..ef59c47 100644 --- a/tests/simple.test.ts +++ b/tests/simple.test.ts @@ -1,4 +1,4 @@ -import { test, expect } from "vitest"; +import { test, expect, describe } from "vitest"; import { CronosExpression } from "../src/index.js"; test("Every minute (* * * * *)", () => { @@ -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 + ]); + }); +});