DEV Community

Casey Marlin
Casey Marlin

Posted on

Bulk-adding events to a calendar app: the .ics format from scratch

Last month I had a spreadsheet with ~40 shifts — date, start, end, who's
on call — and needed them in Google Calendar. The obvious route is
Google's CSV import. It exists, and it half-works: dates get parsed
US-style (03/04 is March 4th whether you meant that or not), there's no
timezone column, all-day events hinge on a magic All Day Event column,
and malformed rows are dropped without a word. Apple Calendar won't take
CSV at all.

The format every calendar app does agree on is iCalendar — the
.ics file, RFC 5545. And it's just text, so you can generate it
yourself.

A minimal .ics file

BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//demo//csv-export//EN
BEGIN:VEVENT
UID:20260901-0900-standup@example.com
DTSTAMP:20260812T120000Z
DTSTART;TZID=Europe/Berlin:20260901T090000
DTEND;TZID=Europe/Berlin:20260901T100000
SUMMARY:Team standup
END:VEVENT
END:VCALENDAR
Enter fullscreen mode Exit fullscreen mode

One VEVENT block per spreadsheet row. Three properties are mandatory:
UID, DTSTAMP, DTSTART. A useful side effect of UID: calendar apps
dedupe on it, so re-importing a corrected file doesn't create 40
duplicates. Derive it from the row data — don't randomize it.

Dates are where imports go to die

.ics has three flavors of timestamp, and mixing them up is the #1
source of "my events are six hours off":

  • DTSTART:20260901T090000Z — trailing Z means UTC.
  • DTSTART;TZID=Europe/Berlin:20260901T090000 — wall-clock time in a named IANA timezone. Usually what you want.
  • DTSTART:20260901T090000 — no Z, no TZID: "floating" time, rendered in whatever timezone the viewer's calendar uses.

All-day events are their own trap:

DTSTART;VALUE=DATE:20260901
DTEND;VALUE=DATE:20260902
Enter fullscreen mode Exit fullscreen mode

DTEND is exclusive — a one-day event ends on the next date.
Skip the +1 and multi-day events render one day short.

The two rules nobody reads the spec for

1. Line endings are CRLF. Most parsers forgive plain \n; Outlook
historically hasn't. Join with \r\n and stop worrying.

2. Lines fold at 75 octets. Long DESCRIPTION values get split, and
each continuation line starts with a single space:

DESCRIPTION:Quarterly review with the plat
 form team — bring the Q3 numbers
Enter fullscreen mode Exit fullscreen mode

It's 75 octets, not characters — emoji and CJK text hit the limit
sooner because UTF-8 is multibyte. And inside TEXT values, , ; \
must be backslash-escaped; literal newlines become \n.

Wiring it up in JavaScript

const esc = s => String(s ?? "")
  .replace(/\\/g, "\\\\").replace(/([,;])/g, "\\$1")
  .replace(/\r?\n/g, "\\n");

const fold = line => {
  if (new TextEncoder().encode(line).length <= 75) return line;
  const out = []; let buf = "";
  for (const ch of line) {
    if (new TextEncoder().encode(buf + ch).length > (out.length ? 74 : 75)) {
      out.push(buf); buf = ch;
    } else buf += ch;
  }
  out.push(buf);
  return out.join("\r\n ");
};

const event = row => [
  "BEGIN:VEVENT",
  `UID:${row.date}-${row.start}@yourdomain.example`,
  `DTSTAMP:${new Date().toISOString().replace(/[-:]|\.\d{3}/g, "")}`,
  `DTSTART;TZID=${row.tz}:${row.date}T${row.start}00`,
  `DTEND;TZID=${row.tz}:${row.date}T${row.end}00`,
  `SUMMARY:${esc(row.title)}`,
].map(fold).join("\r\n") + "\r\nEND:VEVENT";

const ics = ["BEGIN:VCALENDAR", "VERSION:2.0",
  "PRODID:-//you//csv-to-ics//EN",
  ...rows.map(event), "END:VCALENDAR"].join("\r\n") + "\r\n";
Enter fullscreen mode Exit fullscreen mode

Serve it as text/calendar, or hand it to
URL.createObjectURL(new Blob([ics])) for a download link. Recurring
events are one property away (RRULE:FREQ=WEEKLY;BYDAY=MO,WE) — a
rabbit hole worth its own post.

I eventually packaged all of this — column mapping, timezone selection,
folding, escaping, the all-day off-by-one — into
csvtoics.app, a free browser-based converter
(File API end to end, nothing uploads). As of this week it also reads
.xlsx directly — SheetJS in the browser — so the Save-As-CSV step, and
the locale date-mangling that comes with it, is gone entirely. Useful if
you have a spreadsheet right now and no appetite for the spec.

Either way: after generating, import into Google Calendar and open the
file in Apple Calendar or Outlook once. The strict parsers are the ones
that find your bugs.

Top comments (0)