Skip to content
Merged
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -267,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. |
Expand Down
62 changes: 62 additions & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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). */
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -429,6 +469,7 @@ export class RRuleTemporal {
}
opts.bySetPos = this.sanitizeNumericArray(opts.bySetPos, -Infinity, Infinity, false, false);
}
this.enforceStrictRfc(opts);
return opts;
}

Expand Down Expand Up @@ -2101,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.
Expand Down
56 changes: 56 additions & 0 deletions src/tests/rrule-temporal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
26 changes: 26 additions & 0 deletions src/tests/rrule_general.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down