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
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.
Top comments (0)