Creating a downloadable.ics file

In the process of building an online appointment booking app and wanted to provide the user with the ability to add their confirm appointment to their calendar via an ‘Add to Calendar’ button.

After a discussion and some advice from Charles I have managed to implement this using the following technique so thought it would be a good thing to post in case anyone else wants to achieve this.

My approach is based on nwcell’s Github repository which allows for a fully JS approach. In BF I did the following:

  1. Created an HTML element on my page with a button containing and @click=“namedAction(‘addToCalendar’,{})” attribute.

  2. In Misc I added the addToCalendar namedAction with a function - Runs Javascript action.

  3. In the function I loaded the JS from the Github example.html and then updated the cal_single.addEvent() function to use the required values from my BF model.

  4. Added the cal_single.download() function which in example.html was part of the button below the JS and cal_single.addEvent() function.

Points of Interest:

  1. Spent ages trying to get the ics DTSTART & DTEND values to a format that worked using moment.js. I tried to get them to the format show in the ics file which is YYYYMMDDTHHmmss. After much head scratching I managed to find the allowed format in the JS (step 3 above). For a date and time the format required is ddd MMM DD YYYY HH:mm:ss

  2. I tweaked the JS (step 3 again) to modify the ics card to include a reminder 60 minutes before the start of the appointment by adding:

“BEGIN:VALARM”,
“ACTION:DISPLAY”,
“DESCRIPTION:REMINDER”,
“TRIGGER:-PT60M”,
“END:VALARM”,

before the final “END:VEVENT” tag. This could probably be added to the cal_single.addEvent() as an additional parameter by someone with better JS skills than I have!

  1. If you want to pass in a full LOCATION make sure the commas between the elements of the address are preceded by a backslash i.e. , as otherwise only the first line of the address is added.
3 Likes

Update

Only works on desktop. On mobile the ics file is saved as a txt file and doesn’t therefore add to the phone’s calendar. If I identify a fix I’ll post with an update.

1 Like

So having trawled Google I’ve found an alternative JS script that works on mobile. Steps 1 & 2 in the initial post remain unchanged but the following JS should be used as part of the addToCalendar namedAction in Misc:

//name of event in iCal
 this.eventName = model.eventName;

 //description
 this.descriptionName = model.descriptionName;

 //location (must be in format Street\, City\, State\, Zip\, Country)
 this.locationName = model.locationName;

 //name of file to download as
 this.fileName = 'event.ics';

 //start time of event in iCal (I use moment.js to format my start time to the required YYYYMMDDTHHMMSSZ)
 this.dateStart = moment(model.startDate, 'MM/DD/YYYY').format('YYYYMMDD') + 'T' + moment(model.startTime, 'HH:mm:ss').format('HHmmss');

 //end time of event in iCal (My end time is based on startTime + duration but you could replace with a fixed end time.
 this.dateEnd = moment(model.startDate, 'MM/DD/YYYY').format('YYYYMMDD') + 'T' + moment(model.startTime, 'HH:mm:ss').add(model.duration, 'minutes').format('HHmmss');
 
 //alert time (I set a fixed 1hr reminder again based on my start time)
 this.dateAlert = moment(model.startDate, 'MM/DD/YYYY').format('YYYYMMDD') + 'T' + moment(model.startTime, 'HH:mm:ss').subtract(60, 'minutes').format('HHmmss');


 //helper functions

 //iso date for ical formats
 this._isofix = function(d) {
     var offset = ("0" + ((new Date()).getTimezoneOffset() / 60)).slice(-2);

     if (typeof d == 'string') {
         return d.replace(/\-/g, '') + 'T' + offset + '0000Z';
     } else {
         return d.getFullYear() + this._zp(d.getMonth() + 1) + this._zp(d.getDate()) + 'T' + this._zp(d.getHours()) + "0000Z";
     }
 }

 //zero padding for data fixes
 this._zp = function(s) {
     return ("0" + s).slice(-2);
 }
 this._save = function(fileURL) {
     if (!window.ActiveXObject) {
         var save = document.createElement('a');
         save.href = fileURL;
         save.target = '_blank';
         save.download = this.fileName || 'unknown';

         var evt = new MouseEvent('click', {
             'view': window,
             'bubbles': true,
             'cancelable': false
         });
         save.dispatchEvent(evt);

         (window.URL || window.webkitURL).revokeObjectURL(save.href);
     }

     // for IE < 11
     else if (!!window.ActiveXObject && document.execCommand) {
         var _window = window.open(fileURL, '_blank');
         _window.document.close();
         _window.document.execCommand('SaveAs', true, this.fileName || fileURL)
         _window.close();
     }
 }


 var now = new Date();
 var ics_lines = [
     "BEGIN:VCALENDAR",
     "VERSION:2.0",
     "PRODID:-//Addroid Inc.//iCalAdUnit//EN",
     "METHOD:REQUEST",
     "BEGIN:VEVENT",
     "UID:event-" + now.getTime() + "@addroid.com",
     "DTSTAMP:" + this._isofix(now),
     "DTSTART:" + this.dateStart,
     "DTEND:" + this.dateEnd,
     "DESCRIPTION:" + this.procedureName,
     "SUMMARY:" + this.eventName,
     "LOCATION:" + this.locationName,
     "LAST-MODIFIED:" + this._isofix(now),
     "SEQUENCE:0",
     "BEGIN:VALARM",
     "ACTION:DISPLAY",
     "DESCRIPTION:REMINDER",
     "TRIGGER;VALUE=DATE-TIME:" + this.dateAlert,
     "END:VALARM",
     "END:VEVENT",
     "END:VCALENDAR"
 ];

 var dlurl = 'data:text/calendar;base64,' + btoa(ics_lines.join('\r\n'));

 try {
     this._save(dlurl);
 } catch (e) {
     console.log(e);
 }

Hope this is of some help.

2 Likes

Thanks for the update on this.

Thanks for this Neil - extremely well documented and works like a charm!

1 Like

I expanded on Neil’s work a bit to create an URL for Google Calendar. For my app, I have two buttons - one for Apple/iCal (which uses Neil’s solution), another for Google (JS below).

Note that the timestamps in my data model are already in ISO format - if not, you will need to specify the format for moment to parse it properly.

Also note that Google does not allow an alert time to be set. Google does not publish documentation, I found expected URL formatting here: add-event-to-calendar-docs/google.md at main · InteractionDesignFoundation/add-event-to-calendar-docs · GitHub

this.eventName = "Radon Test Stop Reminder";

this.sourceURL = "register.aelabs.com";

this.sourceName = "Alpha Energy Labs Test Registration";

//description
 this.descriptionName = "This a reminder to stop your radon test.";

//location (Google Maps formatting)
this.locationName = [model.form.testAdd.address1, " ", model.form.testAdd.city, ", ", model.form.testAdd.state, " ", model.form.testAdd.zip, " ", country].join("");

//start time of event (I use moment.js to format my start time to the required YYYYMMDDTHHMMSSZ)
this.dateStart = moment(model.form.exp.reminderTimestamp).format('YYYYMMDD') + 'T' + moment(model.form.exp.reminderTimestamp).format('HHmmss');

//end time of event (fixed at 30 minutes)
this.dateEnd = moment(model.form.exp.reminderTimestamp).format('YYYYMMDD') + 'T' + moment(model.form.exp.reminderTimestamp).add(30, 'minutes').format('HHmmss');

//get local time zone name
this.tz = Intl.DateTimeFormat().resolvedOptions().timeZone;

// generate the google URL

// base url
let url = "https://calendar.google.com/calendar/render?action=TEMPLATE";

url += "&dates=" + this.dateStart + "%2F" + this.dateEnd;
// add details (if set)
if (this.eventName !== null && this.eventName !== "") {
    url += "&text=" + encodeURIComponent(this.eventName);
}
if (this.locationName !== null && this.locationName !== "") {
    url += "&location=" + encodeURIComponent(this.locationName);
}
if (this.descriptionName !== null && this.descriptionName !== "") {
    url += "&details=" + encodeURIComponent(this.descriptionName);
}
if ( this.tz !== null && this.tz !== "") {
    url += "&ctz=" + encodeURIComponent(this.tz);
}
if ( this.sourceName !== null && this.sourceName !== "") {
    url += "&sprop=name:" + encodeURIComponent(this.sourceName);
}
if ( this.sourceURL !== null && this.sourceURL !== "") {
    url += "&sprop=website:" + encodeURIComponent(this.sourceURL);
}

window.open(url, "_blank").focus();

For anyone wanting a more elegant UI solution for multiple calendar formats, I found this, but have not tested/implemented it myself: Add to Calendar Button | JavaScript Auto-Generator Snippet

Nice one. Clearly showing my bias towards Apple users :joy: