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
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:[email protected]
DTSTAMP:20260812T120000Z
DTSTART;TZID=Europe/Berlin:20260901T090000
DTEND;TZID=Europe/Berlin:20260901T100000
SUMMARY:Team standup
END:VEVENT
END:VCALENDAR
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— trailingZmeans UTC. -
DTSTART;TZID=Europe/Berlin:20260901T090000— wall-clock time in a named IANA timezone. Usually what you want. -
DTSTART:20260901T090000— noZ, noTZID: "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
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
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";
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.
Originally published by Dev.to WebDev. Aggregated on AIWithGhost for educational purposes — full credit and traffic to the original publisher.