Created
May 8, 2020 20:50
-
-
Save opensourcekam/2d0b80a004438c8e9f345fa5278d5d2c to your computer and use it in GitHub Desktop.
A calendar service for exporting to various calendar types
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import moment from 'moment'; | |
export type CalendarEvent = { | |
title: string; | |
description: string; | |
location: string; | |
startDate: string; | |
endDate: string; | |
}; | |
class CalendarExport { | |
public event: CalendarEvent; | |
constructor(event: CalendarEvent) { | |
this.event = event; | |
} | |
private getRandomKey() { | |
let n = Math.floor(Math.random() * 999999999999).toString(); | |
return new Date().getTime().toString() + '_' + n; | |
} | |
private formatTime(date: string) { | |
let formattedDate = moment.utc(date).format('YYYYMMDDTHHmmssZ'); | |
return formattedDate.replace('+00:00', 'Z'); | |
} | |
private calculateDuration( | |
startDate: CalendarEvent['startDate'], | |
endDate: CalendarEvent['endDate'], | |
) { | |
// snag parameters and format properly in UTC | |
let end = moment.utc(endDate).format('DD/MM/YYYY HH:mm:ss'); | |
let start = moment.utc(startDate).format('DD/MM/YYYY HH:mm:ss'); | |
// calculate the difference in milliseconds between the start and end times | |
let difference = moment(end, 'DD/MM/YYYY HH:mm:ss').diff(moment(start, 'DD/MM/YYYY HH:mm:ss')); | |
// convert difference from above to a proper momentJs duration object | |
let duration = moment.duration(difference); | |
return Math.floor(duration.asHours()) + moment.utc(difference).format(':mm'); | |
} | |
// determine if a mobile browser is being used | |
private isMobile() { | |
let mobile = false; | |
(function (a) { | |
if ( | |
/(android|bb\d+|meego).+mobile|avantgo|bada\/|blackberry|blazer|compal|elaine|fennec|hiptop|iemobile|ip(hone|od)|iris|kindle|lge |maemo|midp|mmp|mobile.+firefox|netfront|opera m(ob|in)i|palm( os)?|phone|p(ixi|re)\/|plucker|pocket|psp|series(4|6)0|symbian|treo|up\.(browser|link)|vodafone|wap|windows ce|xda|xiino/i.test( | |
a, | |
) || | |
/1207|6310|6590|3gso|4thp|50[1-6]i|770s|802s|a wa|abac|ac(er|oo|s\-)|ai(ko|rn)|al(av|ca|co)|amoi|an(ex|ny|yw)|aptu|ar(ch|go)|as(te|us)|attw|au(di|\-m|r |s )|avan|be(ck|ll|nq)|bi(lb|rd)|bl(ac|az)|br(e|v)w|bumb|bw\-(n|u)|c55\/|capi|ccwa|cdm\-|cell|chtm|cldc|cmd\-|co(mp|nd)|craw|da(it|ll|ng)|dbte|dc\-s|devi|dica|dmob|do(c|p)o|ds(12|\-d)|el(49|ai)|em(l2|ul)|er(ic|k0)|esl8|ez([4-7]0|os|wa|ze)|fetc|fly(\-|_)|g1 u|g560|gene|gf\-5|g\-mo|go(\.w|od)|gr(ad|un)|haie|hcit|hd\-(m|p|t)|hei\-|hi(pt|ta)|hp( i|ip)|hs\-c|ht(c(\-| |_|a|g|p|s|t)|tp)|hu(aw|tc)|i\-(20|go|ma)|i230|iac( |\-|\/)|ibro|idea|ig01|ikom|im1k|inno|ipaq|iris|ja(t|v)a|jbro|jemu|jigs|kddi|keji|kgt( |\/)|klon|kpt |kwc\-|kyo(c|k)|le(no|xi)|lg( g|\/(k|l|u)|50|54|\-[a-w])|libw|lynx|m1\-w|m3ga|m50\/|ma(te|ui|xo)|mc(01|21|ca)|m\-cr|me(rc|ri)|mi(o8|oa|ts)|mmef|mo(01|02|bi|de|do|t(\-| |o|v)|zz)|mt(50|p1|v )|mwbp|mywa|n10[0-2]|n20[2-3]|n30(0|2)|n50(0|2|5)|n7(0(0|1)|10)|ne((c|m)\-|on|tf|wf|wg|wt)|nok(6|i)|nzph|o2im|op(ti|wv)|oran|owg1|p800|pan(a|d|t)|pdxg|pg(13|\-([1-8]|c))|phil|pire|pl(ay|uc)|pn\-2|po(ck|rt|se)|prox|psio|pt\-g|qa\-a|qc(07|12|21|32|60|\-[2-7]|i\-)|qtek|r380|r600|raks|rim9|ro(ve|zo)|s55\/|sa(ge|ma|mm|ms|ny|va)|sc(01|h\-|oo|p\-)|sdk\/|se(c(\-|0|1)|47|mc|nd|ri)|sgh\-|shar|sie(\-|m)|sk\-0|sl(45|id)|sm(al|ar|b3|it|t5)|so(ft|ny)|sp(01|h\-|v\-|v )|sy(01|mb)|t2(18|50)|t6(00|10|18)|ta(gt|lk)|tcl\-|tdg\-|tel(i|m)|tim\-|t\-mo|to(pl|sh)|ts(70|m\-|m3|m5)|tx\-9|up(\.b|g1|si)|utst|v400|v750|veri|vi(rg|te)|vk(40|5[0-3]|\-v)|vm40|voda|vulc|vx(52|53|60|61|70|80|81|83|85|98)|w3c(\-| )|webc|whit|wi(g |nc|nw)|wmlb|wonu|x700|yas\-|your|zeto|zte\-/i.test( | |
a.substr(0, 4), | |
) | |
) | |
mobile = true; | |
// @ts-ignore | |
})(window.navigator.userAgent || window.navigator.vendor || window?.opera); | |
return mobile; | |
} | |
private buildUrl(event: CalendarEvent, type: string, isIE: boolean) { | |
let calendarUrl = ''; | |
// allow mobile browsers to open the gmail data URI within native calendar app | |
// type = (type == "google" && this.isMobile()) ? "outlook" : type; | |
switch (type) { | |
case 'google': | |
calendarUrl = 'https://calendar.google.com/calendar/render'; | |
calendarUrl += '?action=TEMPLATE'; | |
calendarUrl += '&dates=' + this.formatTime(event.startDate); | |
calendarUrl += '/' + this.formatTime(event.endDate); | |
calendarUrl += '&location=' + encodeURIComponent(event.location); | |
calendarUrl += '&text=' + encodeURIComponent(event.title); | |
calendarUrl += '&details=' + encodeURIComponent(event.description); | |
break; | |
case 'yahoo': | |
// yahoo doesn't utilize endDate so we need to calulate duration | |
let duration = this.calculateDuration(event.startDate, event.endDate); | |
calendarUrl = 'https://calendar.yahoo.com/?v=60&view=d&type=20'; | |
calendarUrl += '&title=' + encodeURIComponent(event.title); | |
calendarUrl += '&st=' + this.formatTime(event.startDate); | |
calendarUrl += '&dur=' + duration; | |
calendarUrl += '&desc=' + encodeURIComponent(event.description); | |
calendarUrl += '&in_loc=' + encodeURIComponent(event.location); | |
break; | |
case 'outlookcom': | |
calendarUrl = 'https://outlook.live.com/owa/?rru=addevent'; | |
calendarUrl += '&startdt=' + this.formatTime(event.startDate); | |
calendarUrl += '&enddt=' + this.formatTime(event.endDate); | |
calendarUrl += '&subject=' + encodeURIComponent(event.title); | |
calendarUrl += '&location=' + encodeURIComponent(event.location); | |
calendarUrl += '&body=' + encodeURIComponent(event.description); | |
calendarUrl += '&allday=false'; | |
calendarUrl += '&uid=' + this.getRandomKey(); | |
calendarUrl += '&path=/calendar/view/Month'; | |
console.log(calendarUrl); | |
break; | |
default: | |
calendarUrl = [ | |
'BEGIN:VCALENDAR', | |
'VERSION:2.0', | |
'BEGIN:VEVENT', | |
'URL:' + document.URL, | |
'DTSTART:' + this.formatTime(event.startDate), | |
'DTEND:' + this.formatTime(event.endDate), | |
'SUMMARY:' + event.title, | |
'DESCRIPTION:' + event.description, | |
'LOCATION:' + event.location, | |
'END:VEVENT', | |
'END:VCALENDAR', | |
].join('\n'); | |
if (!isIE && this.isMobile()) { | |
calendarUrl = encodeURI('data:text/calendar;charset=utf8,' + calendarUrl); | |
} | |
} | |
return calendarUrl; | |
} | |
public onExportEvent(type: string) { | |
const isIE = !!(typeof window !== 'undefined' && window.navigator.msSaveOrOpenBlob && window.Blob); | |
const url = this.buildUrl(this.event, type, isIE); | |
if (!this.isMobile() && (url.startsWith('data') || url.startsWith('BEGIN'))) { | |
let filename = `${this.event.title}.ics`; | |
let blob = new Blob([url], { type: 'text/calendar;charset=utf-8' }); | |
if (isIE) { | |
window.navigator.msSaveOrOpenBlob(blob, filename); | |
} else { | |
/**************************************************************** | |
// many browsers do not properly support downloading data URIs | |
// (even with "download" attribute in use) so this solution | |
// ensures the event will download cross-browser | |
****************************************************************/ | |
let link = document.createElement('a'); | |
link.href = window.URL.createObjectURL(blob); | |
link.setAttribute('download', filename); | |
document.body.appendChild(link); | |
link.click(); | |
document.body.removeChild(link); | |
} | |
} else { | |
window.open(url, '_blank'); | |
} | |
} | |
} | |
export { CalendarExport }; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import React, { ReactElement, ReactChild } from 'react'; | |
import { css } from 'styled-components/macro'; | |
import { Popover, PopoverMenu, PopoverMenuItem, PopoverToggle } from '@thewing/components'; | |
import { CalendarExport, CalendarEvent } from 'services/CalendarExport'; | |
const popoverButton = css` | |
background: transparent; | |
border: none; | |
padding: 0; | |
`; | |
const popoverMenuStyle = css` | |
#popover-menu-arrow { | |
&::after { | |
background: var(--navy); | |
} | |
} | |
#popover-menu { | |
background: var(--navy); | |
} | |
`; | |
const popoverMenuItemStyle = css` | |
color: var(--white); | |
&:hover, | |
&:focus-within { | |
background: var(--white); | |
color: var(--navy); | |
} | |
`; | |
type CalOptions = { | |
show: boolean; | |
text: string; | |
}; | |
type Props = { | |
children?: ReactChild; | |
event: CalendarEvent; | |
types: { | |
apple?: CalOptions; | |
google?: CalOptions; | |
outlookcom?: CalOptions; | |
}; | |
}; | |
const defaultTypes = { | |
apple: { | |
show: true, | |
text: 'Add to Apple', | |
}, | |
google: { | |
show: true, | |
text: 'Add to Google', | |
}, | |
outlookcom: { | |
show: true, | |
text: 'Add to Outlook', | |
}, | |
}; | |
const ExportToCalendar = ({ types = defaultTypes, event, children }: Props): ReactElement => { | |
const cal = new CalendarExport(event); | |
return ( | |
<Popover> | |
<PopoverToggle css={popoverButton}>{children}</PopoverToggle> | |
<PopoverMenu css={popoverMenuStyle}> | |
{Object.entries(types) | |
.filter(([_key, value]: [string, CalOptions | undefined]) => value?.show) | |
.map(([key, value]: [string, CalOptions | undefined]) => ( | |
<PopoverMenuItem | |
css={popoverMenuItemStyle} | |
onSelect={() => cal.onExportEvent(key)} | |
> | |
{value?.text} | |
</PopoverMenuItem> | |
))} | |
</PopoverMenu> | |
</Popover> | |
); | |
}; | |
export { ExportToCalendar }; |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment