80 Lines Instead of a Dependency: Custom RRULE Parser
80 Lines Instead of a Dependency: Custom RRULE Parser
The rrule npm package is 35kB. The calendar component needs a tiny fraction of it. 80 lines parse the RFC 5545 subset that actually matters.
When building a calendar component that handles recurring events, the standard solution is installing the rrule npm package. "Every Monday," "first 10 occurrences," "weekly until January 31" — these rules come in RRULE format: FREQ=WEEKLY;INTERVAL=1;BYDAY=MO,WE,FR. But how much of that package do you actually use?
kui-react's Calendar has its own parser in modules/app/Calendar/rrule.ts. No external dependency. 80 lines. Working in production.
What RRULE Is
RFC 5545 (the iCalendar spec) represents recurrence rules as a string:
RRULE:FREQ=WEEKLY;INTERVAL=2;BYDAY=TU,TH;COUNT=10
This means "every 2 weeks, on Tuesday and Thursday, for 10 total occurrences." The parser takes this string, produces a data structure, and expandRRule() generates the actual dates from that structure.
The ParsedRRule Type
export type RRuleFreq = 'DAILY' | 'WEEKLY' | 'MONTHLY' | 'YEARLY';
export interface ParsedRRule {
freq: RRuleFreq;
interval: number; // defaults to 1
count?: number; // total occurrences
until?: Date; // end date
byDay?: string[]; // ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU']
}
Supported tokens: FREQ, INTERVAL, COUNT, UNTIL, BYDAY. These five cover 95% of real calendar use cases.
parseRRule()
const DAY_MAP: Record<string, string> = {
MO: 'MO', TU: 'TU', WE: 'WE', TH: 'TH',
FR: 'FR', SA: 'SA', SU: 'SU',
};
export function parseRRule(rruleString: string): ParsedRRule | null {
const str = rruleString.replace(/^RRULE:/i, '');
const params: Record<string, string> = {};
for (const part of str.split(';')) {
const eqIdx = part.indexOf('=');
if (eqIdx === -1) continue;
params[part.slice(0, eqIdx).toUpperCase()] = part.slice(eqIdx + 1);
}
const freq = params.FREQ as RRuleFreq;
if (!freq || !['DAILY', 'WEEKLY', 'MONTHLY', 'YEARLY'].includes(freq)) {
return null;
}
const result: ParsedRRule = {
freq,
interval: params.INTERVAL ? parseInt(params.INTERVAL, 10) : 1,
};
if (params.COUNT) result.count = parseInt(params.COUNT, 10);
if (params.UNTIL) result.until = parseUntilDate(params.UNTIL);
if (params.BYDAY) {
result.byDay = params.BYDAY
.split(',')
.map((d) => d.trim().toUpperCase())
.filter((d) => DAY_MAP[d]);
}
return result;
}
split(';') splits into tokens, indexOf('=') splits each token into key-value. No regex beyond the prefix strip. The RRULE: prefix is stripped — some calendar backends send it, some don't.
null return: if FREQ is missing or invalid, parse fails. The caller handles null with a fallback.
expandRRule() — Generating Dates
export function expandRRule(
rule: ParsedRRule,
dtstart: Date,
rangeStart: Date,
rangeEnd: Date,
): Date[] {
const dates: Date[] = [];
let current = new Date(dtstart);
let count = 0;
const maxIterations = 1000; // infinite loop guard
let iterations = 0;
while (iterations < maxIterations) {
iterations++;
if (rule.until && current > rule.until) break;
if (rule.count !== undefined && count >= rule.count) break;
if (matchesByDay(current, rule.byDay)) {
if (current >= rangeStart && current <= rangeEnd) {
dates.push(new Date(current));
}
count++;
}
if (!advance(current, rule)) break;
}
return dates;
}
rangeStart and rangeEnd parameters are key: the Calendar only needs dates visible in the current view. A "daily for 10 years" rule doesn't generate all 3,650 dates — only the ones in the displayed week.
maxIterations = 1000 prevents an infinite loop from a malformed RRULE string. Even bad input can't freeze the browser.
advance() — Frequency Logic
function advance(date: Date, rule: ParsedRRule): boolean {
const prev = new Date(date);
switch (rule.freq) {
case 'DAILY':
date.setDate(date.getDate() + rule.interval);
break;
case 'WEEKLY': {
if (rule.byDay && rule.byDay.length > 1) {
advanceToNextByDay(date, rule.byDay, rule.interval);
} else {
date.setDate(date.getDate() + 7 * rule.interval);
}
break;
}
case 'MONTHLY':
date.setMonth(date.getMonth() + rule.interval);
break;
case 'YEARLY':
date.setFullYear(date.getFullYear() + rule.interval);
break;
}
return date > prev; // advancement guarantee
}
WEEKLY + byDay is a special case: FREQ=WEEKLY;BYDAY=MO,WE,FR for a week starting Sunday should produce Monday → Wednesday → Friday → (next week) Monday. advanceToNextByDay() handles this logic.
return date > prev is a safety check: if the date mutation somehow didn't advance (defensive programming), the loop breaks.
matchesByDay() — Day Matching
const WEEKDAY_NAMES = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'] as const;
function matchesByDay(date: Date, byDay?: string[]): boolean {
if (!byDay || byDay.length === 0) return true;
const dayName = WEEKDAY_NAMES[date.getDay()];
return byDay.includes(dayName);
}
Date.getDay() returns 0=Sunday, 6=Saturday. RRULE spec uses SU=Sunday. The WEEKDAY_NAMES array resolves this mapping by index.
Drop-in Compatibility
// With the rrule npm package
import { RRule } from 'rrule';
const rule = RRule.fromString('FREQ=WEEKLY;BYDAY=MO,WE');
const dates = rule.between(rangeStart, rangeEnd);
// With kui-react parseRRule
const parsed = parseRRule('FREQ=WEEKLY;BYDAY=MO,WE');
const dates = parsed ? expandRRule(parsed, dtstart, rangeStart, rangeEnd) : [];
The API shape is different, but the output is equivalent. A comment in the source explicitly notes "drop-in compatible with rrule npm package for the subset we use." This is a declared intent — if the full package is needed later, the interface change is contained.
Why Not Just Use the Package?
The rrule package (and its TypeScript successor rrule-ts) implements the full RFC 5545:
BYMONTH,BYMONTHDAY,BYYEARDAY,BYWEEKNOBYSETPOS(nth occurrence in a set)WKST(week start day)- Timezone-aware expansion
- Complex ordinal rules: "last Friday," "second Monday"
The calendar component uses none of these. FREQ, INTERVAL, COUNT, UNTIL, BYDAY — five tokens. Installing the full package adds 35kB for features that will never be called.
This isn't a principle. It's a pragmatic decision. If a client required "last business day of each month" or timezone-aware scheduling, installing the package would be the right call.
Edge Cases
UNTIL comes in two formats: 20251231T235959Z (datetime) and 20251231 (date-only). Google Calendar and Outlook send different formats. parseUntilDate() handles both:
function parseUntilDate(until: string): Date {
if (until.includes('T')) {
return new Date(
until.replace(
/^(\d{4})(\d{2})(\d{2})T(\d{2})(\d{2})(\d{2})Z?$/,
'$1-$2-$3T$4:$5:$6Z',
),
);
}
return new Date(
until.replace(/^(\d{4})(\d{2})(\d{2})$/, '$1-$2-$3'),
);
}
DST (daylight saving time) edge case: setDate(getDate() + 7) can shift the time by an hour across DST transitions. For hour-precise scheduling, a timezone-aware library like date-fns-tz or luxon is required. The calendar component operates at day granularity — this edge case is irrelevant.
Trade-off Summary
| Custom parser | rrule package | |
|---|---|---|
| Bundle | ~2kB (minified) | ~35kB |
| Supported tokens | 5 | RFC 5545 full |
| Ordinal rules | No | Yes |
| Timezone-aware | No | Yes |
| Maintenance burden | Owner | Zero |
| Drop-in compat | Manual | Native |
Owning the implementation means bugs are fixed without waiting for a package release. It also means you write the tests.
Business Impact
A SaaS calendar module: recurring meeting planner. The backend sends RRULE strings, the frontend parses them. Without the package, each deploy is 35kB smaller — trivial alone, but meaningful when adding up across many dynamic imports.
More importantly: one fewer dependency in the security audit, no version conflict risk, no breaking change in a future package release affecting this code path.
Something to Try
Look at a package you use for one or two functions. If those functions total fewer than 100 lines of straightforward logic with no external system calls, consider implementing them directly. Write the test first — if you can describe the expected output in a few assertions, the implementation is probably feasible. If the test is hard to write, the scope may be larger than it looks.
Related Articles
Same CategoryComments (0)
Newsletter
Stay updated! Get all the latest and greatest posts delivered straight to your inbox