From c86f1189a63f1e3062b634f47c664085acd6859f Mon Sep 17 00:00:00 2001 From: Gabe Date: Thu, 15 Jan 2026 09:26:28 -0600 Subject: [PATCH 1/2] Add strict RFC 5545 validation option --- README.md | 1 + src/index.ts | 41 +++++++++++++++++++++++++++++++++ src/tests/rrule_general.test.ts | 26 +++++++++++++++++++++ 3 files changed, 68 insertions(+) diff --git a/README.md b/README.md index a306c8d..8e7bb60 100644 --- a/README.md +++ b/README.md @@ -105,6 +105,7 @@ below. These correspond to the recurrence rule parts defined in RFC 5545: | `tzid` | Time zone identifier for interpreting dates. | | `maxIterations` | Safety cap when generating occurrences. | | `includeDtstart` | Include `DTSTART` even if it does not match the pattern. | +| `strict` | Enforce RFC 5545 constraints strictly (defaults to false). | | `dtstart` | First occurrence as `Temporal.ZonedDateTime`. | ## Querying occurrences diff --git a/src/index.ts b/src/index.ts index 73874ec..eeb43e5 100644 --- a/src/index.ts +++ b/src/index.ts @@ -13,6 +13,8 @@ interface BaseOpts { maxIterations?: number; /** Include DTSTART as an occurrence even if it does not match the rule pattern. */ includeDtstart?: boolean; + /** Enforce RFC 5545 constraints strictly (defaults to false). */ + strict?: boolean; /** RSCALE per RFC 7529: calendar system for recurrence generation (e.g., GREGORIAN). */ rscale?: string; /** SKIP behavior per RFC 7529: OMIT (default), BACKWARD, FORWARD (requires RSCALE). */ @@ -344,6 +346,7 @@ export class RRuleTemporal { // Allow explicit COUNT/UNTIL overrides when omitted from the RRULE string count: params.count ?? parsed.count, until: params.until ?? parsed.until, + strict: params.strict, maxIterations: params.maxIterations, includeDtstart: params.includeDtstart, tzid: this.tzid, @@ -402,6 +405,43 @@ export class RRuleTemporal { return days.length > 0 ? days : undefined; } + private enforceStrictRfc(opts: ManualOpts) { + if (!opts.strict) return; + + const freq = opts.freq; + if (opts.byWeekNo && freq !== 'YEARLY') { + throw new Error('BYWEEKNO MUST NOT be used unless FREQ=YEARLY'); + } + if (opts.byYearDay && ['DAILY', 'WEEKLY', 'MONTHLY'].includes(freq)) { + throw new Error('BYYEARDAY MUST NOT be used when FREQ is DAILY, WEEKLY, or MONTHLY'); + } + if (opts.byMonthDay && freq === 'WEEKLY') { + throw new Error('BYMONTHDAY MUST NOT be used when FREQ is WEEKLY'); + } + + const hasNumericByDay = (opts.byDay ?? []).some((day) => /^[+-]?\d/.test(day)); + if (hasNumericByDay && !['MONTHLY', 'YEARLY'].includes(freq)) { + throw new Error('BYDAY with numeric value MUST NOT be used unless FREQ is MONTHLY or YEARLY'); + } + if (hasNumericByDay && freq === 'YEARLY' && opts.byWeekNo) { + throw new Error('BYDAY with numeric value MUST NOT be used with FREQ=YEARLY when BYWEEKNO is present'); + } + + const hasOtherBy = Boolean( + opts.byDay || + opts.byMonth || + opts.byMonthDay || + opts.byYearDay || + opts.byWeekNo || + opts.byHour || + opts.byMinute || + opts.bySecond, + ); + if (opts.bySetPos && !hasOtherBy) { + throw new Error('BYSETPOS MUST be used with another BYxxx rule part'); + } + } + private sanitizeOpts(opts: ManualOpts): ManualOpts { opts.byDay = this.sanitizeByDay(opts.byDay); // BYMONTH can include strings (e.g., "5L") under RFC 7529; keep tokens as-is. @@ -429,6 +469,7 @@ export class RRuleTemporal { } opts.bySetPos = this.sanitizeNumericArray(opts.bySetPos, -Infinity, Infinity, false, false); } + this.enforceStrictRfc(opts); return opts; } diff --git a/src/tests/rrule_general.test.ts b/src/tests/rrule_general.test.ts index a277268..e48eac1 100644 --- a/src/tests/rrule_general.test.ts +++ b/src/tests/rrule_general.test.ts @@ -125,6 +125,32 @@ describe('General RRule tests', () => { ).toThrow('bySetPos may not contain 0'); }); + it('strict rejects BYWEEKNO with non-YEARLY freq', () => { + expect( + () => + new RRuleTemporal({ + freq: 'MONTHLY', + count: 1, + byWeekNo: [15], + dtstart: zdt(1997, 9, 2, 9, 'UTC'), + strict: true, + }), + ).toThrow('BYWEEKNO MUST NOT be used unless FREQ=YEARLY'); + }); + + it('strict rejects BYSETPOS without other BYxxx', () => { + expect( + () => + new RRuleTemporal({ + freq: 'MONTHLY', + count: 1, + bySetPos: [1], + dtstart: zdt(1997, 9, 2, 9, 'UTC'), + strict: true, + }), + ).toThrow('BYSETPOS MUST be used with another BYxxx rule part'); + }); + it('testInvalidNthWeekday', () => { expect(() => new RRuleTemporal({freq: 'WEEKLY', byDay: ['0FR'], dtstart: zdt(1997, 9, 2, 9, 'UTC')})).toThrow( 'Invalid BYDAY value', From 8f51deb23b72338da7ea757333273d43b0b66e3d Mon Sep 17 00:00:00 2001 From: Gabe Date: Thu, 15 Jan 2026 10:02:00 -0600 Subject: [PATCH 2/2] Add matches/occursOn helpers and tests --- README.md | 2 ++ src/index.ts | 21 ++++++++++++ src/tests/rrule-temporal.test.ts | 56 ++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+) diff --git a/README.md b/README.md index 8e7bb60..c58d98b 100644 --- a/README.md +++ b/README.md @@ -268,6 +268,8 @@ Notes | `new RRuleTemporal(opts)` | Create a rule from an ICS snippet or manual options. | | `all(iterator?)` | Return every occurrence. When the rule has no end the optional iterator is required. | | `between(after, before, inclusive?)` | Occurrences within a time range. | +| `matches(date)` | Convenience helper: true if the exact instant is an occurrence (accepts `Date` or `Temporal.ZonedDateTime`). | +| `occursOn(date)` | Convenience helper: true if any occurrence falls on the given `Temporal.PlainDate` in the rule's time zone (date-only, ignores time). | | `next(after?, inclusive?)` | Next occurrence after a given date. | | `previous(before?, inclusive?)` | Previous occurrence before a date. | | `toString()` | Convert the rule back into `DTSTART` and `RRULE` lines. | diff --git a/src/index.ts b/src/index.ts index eeb43e5..6ec9b7c 100644 --- a/src/index.ts +++ b/src/index.ts @@ -2142,6 +2142,27 @@ export class RRuleTemporal { }); } + /** + * Convenience helper: true if the exact instant is an occurrence of the rule. + * This checks full date-time equality (including time and time zone). + */ + matches(date: DateFilter): boolean { + return this.between(date, date, true).length > 0; + } + + /** + * Convenience helper: true if any occurrence falls on the given calendar day + * in the rule's time zone. This ignores time-of-day granularity. + */ + occursOn(date: Temporal.PlainDate): boolean { + const startOfDay = date.toZonedDateTime({ + timeZone: this.tzid, + plainTime: Temporal.PlainTime.from('00:00'), + }); + const endOfDay = startOfDay.add({days: 1}).subtract({nanoseconds: 1}); + return this.between(startOfDay, endOfDay, true).length > 0; + } + /** * Returns the next occurrence of the rule after a specified date. * @param after - The start date or Temporal.ZonedDateTime object. diff --git a/src/tests/rrule-temporal.test.ts b/src/tests/rrule-temporal.test.ts index cb0734b..b9f205e 100644 --- a/src/tests/rrule-temporal.test.ts +++ b/src/tests/rrule-temporal.test.ts @@ -118,6 +118,62 @@ describe('RRuleTemporal - between() with distant start and no COUNT', () => { }); }); +describe('RRuleTemporal - matches() and occursOn()', () => { + const rule = new RRuleTemporal({ + freq: 'WEEKLY', + byDay: ['MO', 'WE', 'FR'], + dtstart: Temporal.ZonedDateTime.from('2025-01-01T10:00:00+00:00[UTC]'), + }); + + test('matches returns true for exact occurrences and false otherwise', () => { + const hit = Temporal.ZonedDateTime.from('2025-01-03T10:00:00+00:00[UTC]'); + const miss = Temporal.ZonedDateTime.from('2025-01-03T10:30:00+00:00[UTC]'); + + expect(rule.matches(hit)).toBe(true); + expect(rule.matches(miss)).toBe(false); + expect(rule.matches(new Date('2025-01-03T10:00:00Z'))).toBe(true); + }); + + test('occursOn returns true when any occurrence falls on that day', () => { + const dayHit = Temporal.PlainDate.from('2025-01-03'); + const dayMiss = Temporal.PlainDate.from('2025-01-04'); + + expect(rule.occursOn(dayHit)).toBe(true); + expect(rule.occursOn(dayMiss)).toBe(false); + }); +}); + +describe('RRuleTemporal - matches() respects rDate/exDate', () => { + const base = Temporal.ZonedDateTime.from('2025-01-01T10:00:00+00:00[UTC]'); + const rule = new RRuleTemporal({ + freq: 'DAILY', + dtstart: base, + exDate: [base.add({days: 1})], + rDate: [base.add({days: 4})], + }); + + test('exDate is not a match, rDate is a match', () => { + expect(rule.matches(base.add({days: 1}))).toBe(false); + expect(rule.matches(base.add({days: 4}))).toBe(true); + }); +}); + +describe('RRuleTemporal - occursOn() uses rule time zone', () => { + const rule = new RRuleTemporal({ + freq: 'WEEKLY', + byDay: ['MO'], + dtstart: Temporal.ZonedDateTime.from('2025-01-06T23:30:00[America/Chicago]'), + }); + + test('occursOn checks local day rather than UTC day', () => { + const mondayLocal = Temporal.PlainDate.from('2025-01-06'); + const tuesdayLocal = Temporal.PlainDate.from('2025-01-07'); + + expect(rule.occursOn(mondayLocal)).toBe(true); + expect(rule.occursOn(tuesdayLocal)).toBe(false); + }); +}); + describe('RRuleTemporal - between() before original dtstart', () => { const rule = new RRuleTemporal({ freq: 'DAILY',