Export and import calendar files
Pure functions turn a list of events into text: eventsToICS for a calendar app and eventsToCSV for a spreadsheet; icsToEvents reads a calendar file back. The export functions return strings, so they work in the browser for a “Download” button and on a server for a feed. They live in their own entry point, resource-scheduler/exchange: no React, no "use client", so a server component or a route handler can import them, and they stay out of the component bundle.
import { eventsToICS, eventsToCSV } from "resource-scheduler/exchange";
const ics = eventsToICS(events, { resources, name: "Meeting rooms" });const csv = eventsToCSV(events, { resources });
function download(name: string, type: string, text: string) { const url = URL.createObjectURL(new Blob([text], { type })); const link = Object.assign(document.createElement("a"), { href: url, download: name }); link.click(); URL.revokeObjectURL(url);}download("rooms.ics", "text/calendar", ics);events is a flat list. If yours live inside the resources, flatten them first and keep the resource id on each:
const events = resources.flatMap((r) => r.events.map((e) => ({ ...e, resourceId: r.id })));resources is optional. With it, an event’s resourceId becomes a name (LOCATION in the calendar file, the resource column in the CSV); without it the id is used in the CSV and the calendar file has no location.
The calendar file
Section titled “The calendar file”- Timed events are written in UTC, so every calendar app places them at the right moment.
- Whole days (an event that starts and ends at local midnight, like a rental) are written as all-day events. The end is exclusive, as it is in iCalendar.
- Recurring events stay one event:
recurrencebecomesRRULE(FREQ,INTERVAL,BYDAY,BYMONTHDAY,BYSETPOS,UNTIL,COUNT, written bytoRRule; “the last Friday” isBYDAY=-1FR) andexceptionsbecomeEXDATE, so the calendar app expands them itself. - Room names as locations. Passing
resourceswrites the resource name asLOCATION. That suits rooms and equipment; for people, leaveresourcesout. - The
UIDis<id>@resource-scheduler, the same on every export, which is what calendar apps use to recognise an event. - Text is escaped and lines are folded at 75 bytes without splitting a character.
The output is covered by tests, and it was also checked by parsing it back with the independent ical.js library: titles with commas, semicolons and non-Latin characters, a long folded title, an all-day event, two locations, and a weekly series with an exception all read back as written.
The spreadsheet file
Section titled “The spreadsheet file”eventsToCSV writes id,title,resource,start,end,description, one row per resource of an event, with local dates like 2026-10-07 09:00. Cells with commas, quotes or line breaks are quoted.
A cell that starts with =, +, - or @ gets a leading '. Event titles often come from users, and a spreadsheet would run such a cell as a formula, which is how a hostile title can make a request or leak data from whoever opens the export. The quote is visible in the cell; strip it if you read the file back.
A recurring event is one row, with its first occurrence. For a row per occurrence, expand the series first:
import { expandEvents } from "resource-scheduler";import { eventsToCSV } from "resource-scheduler/exchange";
const csv = eventsToCSV(expandEvents(events, from, to), { resources });Import
Section titled “Import”icsToEvents reads the events of an .ics file, from Google Calendar, Outlook, Apple Calendar or eventsToICS, into the events the scheduler takes. It works on the text of a file the user picks, so a calendar can be imported without a server.
import { icsToEvents } from "resource-scheduler/exchange";
const events = icsToEvents(await file.text(), { resources, // match each LOCATION to a resource name resourceId: "ann", // events that match none go to this row onWarning: (message) => console.warn(message),});
<ResourceScheduler resources={resources} events={events} />;An event is drawn on the resource it names, so every imported event needs one. LOCATION is matched with the names in resources (ignoring case; a list such as Room A, Room B gives the event both), and resourceId catches the rest, for example a person’s own calendar imported into their row. Without either, an event has no resource and is not drawn.
What is read:
- Times. UTC times (
Z), times in aTZIDzone and floating times become moments; dates become whole local days, with the exclusive end iCalendar uses.DTENDorDURATIONgives the end; an event with neither lasts an hour, or a day when it has a date only. Zones are IANA names such asEurope/Paris, read with the browser’s own time zone data. - Repeats.
RRULEandEXDATEbecomerecurrence: hourly, daily, weekly, monthly and yearly rules, withINTERVAL,COUNT,UNTIL,BYDAY(also “the second Tuesday” as2TU),BYMONTHDAYandBYSETPOS(read byparseRRule). An occurrence that was changed (RECURRENCE-ID) becomes its own event, with the id<uid>#<yyyy-MM-dd>(<uid>#<yyyy-MM-dd>T<HH:mm>for an hourly series), and its day is skipped in the series. - Ids. The
UIDbecomes theid, without the@resource-schedulerthateventsToICSadds, so a round trip keeps your ids. Duplicates get a numeric suffix so ids stay unique. - Text. Titles and descriptions are unescaped and unfolded. They come from whoever wrote the file: render them as text, never as HTML.
What is left out (each one except a cancelled event sends a message to onWarning):
- Cancelled events and cancelled occurrences (
STATUS:CANCELLED). - A rule the scheduler cannot show: a
BYMONTHother than the month the series starts in (or several months),BYYEARDAY,BYWEEKNO, minutely or secondly repeats, different positions on different weekdays (1MO,-1FR),WKSTother than Monday on a rule every n weeks. The event is imported once, on its first day, and the message names the part. - A zone that is not an IANA name, such as Outlook’s “Eastern Standard Time”. Its times are read as local time. If your files come from Outlook, ask for UTC or convert the zone names first.
- An event with no readable start, or one that ends before it starts, and the
RDATElist. - Alarms, tasks, attendees and attachments are ignored without a message.
RRULE strings
Section titled “RRULE strings”parseRRule and toRRule are the rule reader and writer that icsToEvents and eventsToICS use, exported for your own storage, an API or a form. They are pure and live in resource-scheduler/exchange.
import { parseRRule, toRRule } from "resource-scheduler/exchange";
const start = new Date(2026, 9, 30, 9); // DTSTART: the first occurrence
parseRRule("FREQ=MONTHLY;BYDAY=-1FR;COUNT=12", { start });// { freq: "monthly", byWeekday: [5], bySetPos: [-1], count: 12 }
toRRule({ freq: "monthly", byWeekday: [5], bySetPos: [-1] });// "FREQ=MONTHLY;BYDAY=-1FR" (add "RRULE:" for a line of an .ics file)
try { parseRRule("FREQ=YEARLY;BYMONTH=3,9", { start });} catch (error) { // RangeError: BYMONTH=3,9: a yearly series repeats in the month it starts in (10)}- Read:
FREQ(hourly to yearly),INTERVAL,COUNT,UNTIL,BYDAY,BYMONTHDAY,BYSETPOS,BYMONTHfor yearly in the start month, andWKST=MO. The prefixRRULE:and the case are optional.BYDAY=2TUor-1FR(one weekday, any positions) becomebyWeekdayandbySetPos; plain weekdays withBYSETPOSstay as they are. - Not guessed: anything else makes
parseRRulethrow aRangeErrorthat names the part (BYYEARDAY is not supported), rather than dropping it and returning a rule that means something else. The same for values out of range and for a part the frequency cannot take (aBYDAYon a daily rule,BYMONTHDAYon a weekly one). startis the first occurrence (DTSTART). A yearly rule repeats in the month it starts in, soparseRRulechecksBYMONTHagainst it andtoRRulewrites it for a yearly rule with weekdays or month days.DTSTARTandEXDATEare not part of the rule:exceptionsare written and read asEXDATE.UNTILis a calendar day, as forrecurrence.until. For an hourly rule it is the moment.toRRulewrites the end of that day in UTC, or a date with{ allDay: true }.- The output was checked against ical.js: the same occurrences (hourly included, which steps the clock as iCalendar does) for the last Friday, the second Tuesday, the last weekday, month days, Friday the 13th, yearly rules, hourly,
COUNTandUNTIL.
Not included
Section titled “Not included”- Time zones in export. Dates are local
Dates, so the UTC times in the calendar file are for the time zone of the machine that runs the function. A server in another zone writes different times for the same events. - Print styles. Use the browser’s own print for now.