diff --git a/app/config.js b/app/config.js index 0a38ad3e..1cd8a92c 100644 --- a/app/config.js +++ b/app/config.js @@ -101,14 +101,26 @@ const config = { `Shift helps groups and individuals to promote their "bike fun" events.`, }, cal: { - name: 'Shift Bike Calendar', - desc: 'Find fun bike events and make new friends!', - guid: 'shift@shift2bikes.org', - prod: '-//shift2bikes.org//NONSGML shiftcal v2.1//EN', - filename: 'shift-calendar', - ext: '.ics', - maxage: 60*60*3, // 3 hours - } + pedalp: { + name: 'Pedalpalooza Bike Calendar', + desc: 'Find fun Pedalpalooza bike events!', + guid: 'shift@shift2bikes.org', + filename: 'pedalpalooza-calendar', + }, + shift: { + name: 'Shift Community Calendar', + desc: 'Find fun bike events all year round.', + guid: 'community@shift2bikes.org', + filename: 'shift-calendar', + }, + // shared properties: + base: { + ext: '.ics', + maxage: 60*60*3, // 3 hours + // the software that created the calendar + prod: '-//shift2bikes.org//NONSGML shiftcal v2.1//EN', + }, + }, }; module.exports = config; diff --git a/app/endpoints/ical.js b/app/endpoints/ical.js index 675fc606..e595bc55 100644 --- a/app/endpoints/ical.js +++ b/app/endpoints/ical.js @@ -31,20 +31,44 @@ module.exports = { replace, }; +// text|escapeBreak("HEADER") => HEADER:text +// text can be either a string or an array. +nunjucks.addFilter('escapeBreak', function(text, header) { + header += ":"; + return !Array.isArray(text) ? + escapeBreak(header, text) : + escapeBreak(header, ...text); +}); + +// format a dayjs object in a ical friendly way. +nunjucks.addFilter('ical', function(d) { + return dt.icalFormat(d); +}); + function readBool(b) { return b === "true" || b === "1"; } +function readDate(d) { + return d && dt.fromYMDString(d); +} + +// the endpoint handler for all ical feeds. function get(req, res, next) { const id = req.query.id; // a cal event id - const start = req.query.startdate || ""; - const end = req.query.enddate || ""; + const start = readDate(req.query.startdate); + const end = readDate(req.query.enddate); const includeDeleted = readBool(req.query.all); const customName = req.query.filename || ""; + const pedalp = customName.startsWith("pedalp"); + const cal = Object.assign({}, config.cal.base, pedalp? config.cal.pedalp: config.cal.shift); - return getEventData(id, start, end, includeDeleted).then(data => { + return getEventData(cal, id, start, end, includeDeleted).then(data => { const { filename, events } = data; - return respondWith(res, customName || filename, events); + if (pedalp) { + events.push( buildClosingEvent(end) ); + } + return respondWith(cal, res, customName || filename, events); }).catch(err => { // the code below uses strings for expected errors. // ex. a bad range; allow other things to be 500 server errors with stacks. @@ -57,19 +81,23 @@ function get(req, res, next) { } // promise a structure containing: filename and events. -function getEventData(id, start, end, includeDeleted) { +// start and end are dayjs objects ( or false ) +function getEventData(cal, id, start, end, includeDeleted) { let filename; let buildEvents; - const cal= config.cal; if (id && start && end) { + // there's not a real need to validate the parameters like this + // its probably over-zealous and could be removed. buildEvents = Promise.reject("expected either an id or date range"); } else if (id) { - filename = `${cal.filename}-${id}` + cal.ext; // ex. shift-calendar-12414.ics + filename = `${cal.filename}-${id}` + cal.ext; buildEvents = buildOne(id); - } else if (start || end) { + } else if (start && end) { // ex. shift-calendar-2001-06-02-to-2022-01-01.ics - filename = `${cal.filename}-${start}-to-${end}` + cal.ext; + filename = [cal.filename, + dt.toYMDString(start), "to", + dt.toYMDString(end)].join("-") + cal.ext; buildEvents = buildRange(start, end, includeDeleted); } else { // ex. shift-calendar.ics @@ -83,8 +111,7 @@ function getEventData(id, start, end, includeDeleted) { * Turn event entries into a http response. * @see https://datatracker.ietf.org/doc/html/rfc5545#section-3.6.1 */ -function respondWith(res, filename, events) { - const cal = config.cal; +function respondWith(cal, res, filename, events) { // note: the php sets includes utf8 in the content type but... // according to https://en.wikipedia.org/wiki/ICalendar // its default utf8, and mime type should be used for anything different. @@ -122,29 +149,27 @@ function buildOne(id) { function buildCurrent() { const now = dt.getNow(); const started = now.subtract(1, 'month'); - const ended = now.add(3, 'month'); + const ended = now.add(6, 'month'); return CalDaily.getFullRange(started, ended).then((dailies)=>{ return buildEntries(dailies); }); } // Promise a range of events in ical format as string, -// where start and end are timestamps. +// where start and end are dayjs objects. function buildRange(start, end, includeDeleted) { - const started = dt.fromYMDString(start); - const ended = dt.fromYMDString(end); - if (!started.isValid() || !ended.isValid()) { + if (!start.isValid() || !end.isValid()) { return Promise.reject("invalid dates"); } else { - const range = ended.diff(started, 'day'); + const range = end.diff(start, 'day'); if ((range < 0) || (range > 100)) { return Promise.reject("bad date range"); } const q = includeDeleted? CalDaily.getFullRange: CalDaily.getRangeVisible; - return q(started,ended).then((dailies)=>{ - return buildEntries(dailies); + return q(start,end).then((dailies)=>{ + return buildEntries(dailies); }); } } @@ -201,24 +226,60 @@ function buildCalEntry(evt, at) { return { uid: "event-" + at.pkid + "@shift2bikes.org", url, - summary: escapeBreak("SUMMARY:", title), - contact: escapeBreak("CONTACT:", evt.name), - description: escapeBreak("DESCRIPTION:", + summary: title, + contact: evt.name, + description: [ news, evt.descr, evt.timedetails, evt.locend? "Ends at "+ evt.locend: null, - url), - location: escapeBreak("LOCATION:", - evt.locname, evt.address, evt.locdetails), + url + ], + location: [ + evt.locname, evt.address, evt.locdetails + ], status: at.isUnscheduled() ? "CANCELLED": "CONFIRMED", - start: dt.icalFormat( startAt ), - end: dt.icalFormat( endAt ), - created: dt.icalFormat( evt.created ), - modified: dt.icalFormat( evt.modified ), + start: startAt, + end: endAt, + created: evt.created, + modified: evt.modified, sequence: evt.changes + 1, }; } +/** + * Create a fake closing event for pedalp. + * @param dayjs lastDay of Pedalpalooza ( ex. the final day of the month ) + * @return an object containing the elements needed for producing a v-event. + * @see buildCalEntry() + */ +function buildClosingEvent(lastDay) { + const year = lastDay.year(); + const fakeModified = dayjs(`06-01-${year}`, "MM-DD-YYYY"); + const oneDayLater = lastDay.add(1, 'day'); + const url = "https://shift2bikes.org/calendar/"; + return { + // usually: "event-123@shift2bikes.org" + uid: `pedalpalooza-${year}-end@shift2bikes.org`, + summary: `Pedalpalooza ${year} is over!`, + contact: "bikecal@shift2bikes.org", + description: + "We hope you've had a great bike summer and a great Pedalpalooza!!!\n"+ + "While Pedalpalooza is done, there is still plenty of bike fun to be found on the Shift2bikes website. "+ + "And you can also subscribe to the Shift community calendar to see those rides.\n"+ + `Visit ${url} for more details.`, + location: "Portland, and beyond!", + status: "CONFIRMED", + // create an all day event on the day after Pedalpalooza + start: oneDayLater.startOf('day'), + end: oneDayLater.endOf('day'), + created: fakeModified, + modified: fakeModified, + sequence: 1, + url, + }; +} + + // --------------------------------- // the internals: // --------------------------------- diff --git a/app/test/ical_test.js b/app/test/ical_test.js index deca2700..8c9f8bcb 100644 --- a/app/test/ical_test.js +++ b/app/test/ical_test.js @@ -117,14 +117,28 @@ describe("ical feed", () => { done(); }); }); + it("has a special pedalpalooza feed", function(done) { + chai.request( app ) + .get('/api/ical.php') + .query({ + startdate: "2002-08-01", + enddate : "2002-08-02", + filename : "pedalpalooza-2024.ics", + }) + .end(function (err, res) { + expect(err).to.be.null; + expect(res).to.have.status(200); + // expect(res).to.have.header('content-type', 'text/calendar'); + expect(res.text).to.equal(pedalpaloozaFeed); + done(); + }); + }); it("can handle a canceled event", function(done) { CalEvent.getByID(2).then(evt => { - // maybe its just test data, but these are null - // in the mysql data that i have; - // and they generated bad caldata as a result. - // ( while the php works fine ) - evt.eventtime = null; - evt.eventduration = 0; + // todo: create a separate test where these values are nil and zero. + // that had caused a bad feed at one point; its fixed but still good to test. + // evt.eventtime = null; + // evt.eventduration = 0; evt._store().then(_ => { CalDaily.getForTesting(201).then(d => { d.eventstatus = EventStatus.Cancelled; @@ -147,15 +161,25 @@ describe("ical feed", () => { }); }); +const shiftHeader = [ +String.raw`VERSION:2.0`, +String.raw`PRODID:-//shift2bikes.org//NONSGML shiftcal v2.1//EN`, +String.raw`METHOD:PUBLISH`, +String.raw`X-WR-CALNAME:Shift Community Calendar`, +String.raw`X-WR-CALDESC:Find fun bike events all year round.`, +String.raw`X-WR-RELCALID:community@shift2bikes.org`, +]; -const allEvents = [ -String.raw`BEGIN:VCALENDAR`, +const pedalpHeader = [ String.raw`VERSION:2.0`, String.raw`PRODID:-//shift2bikes.org//NONSGML shiftcal v2.1//EN`, String.raw`METHOD:PUBLISH`, -String.raw`X-WR-CALNAME:Shift Bike Calendar`, -String.raw`X-WR-CALDESC:Find fun bike events and make new friends!`, +String.raw`X-WR-CALNAME:Pedalpalooza Bike Calendar`, +String.raw`X-WR-CALDESC:Find fun Pedalpalooza bike events!`, String.raw`X-WR-RELCALID:shift@shift2bikes.org`, +]; + +const event1 = [ String.raw`BEGIN:VEVENT`, String.raw`UID:event-201@shift2bikes.org`, String.raw`SUMMARY:ride 2 title`, @@ -172,6 +196,8 @@ String.raw`DTSTAMP:19930828T090700Z`, String.raw`SEQUENCE:2`, String.raw`URL:http://localhost:3080/calendar/event-201`, String.raw`END:VEVENT`, +]; +const event2 = [ String.raw`BEGIN:VEVENT`, String.raw`UID:event-202@shift2bikes.org`, String.raw`SUMMARY:ride 2 title`, @@ -188,33 +214,26 @@ String.raw`DTSTAMP:19930828T090700Z`, String.raw`SEQUENCE:2`, String.raw`URL:http://localhost:3080/calendar/event-202`, String.raw`END:VEVENT`, +]; + + +const allEvents = [ +String.raw`BEGIN:VCALENDAR`, +...shiftHeader, +...event1, +...event2, String.raw`END:VCALENDAR`, "" // trailing new line. i think. ].join("\r\n"); - const emptyRange = [ String.raw`BEGIN:VCALENDAR`, -String.raw`VERSION:2.0`, -String.raw`PRODID:-//shift2bikes.org//NONSGML shiftcal v2.1//EN`, -String.raw`METHOD:PUBLISH`, -String.raw`X-WR-CALNAME:Shift Bike Calendar`, -String.raw`X-WR-CALDESC:Find fun bike events and make new friends!`, -String.raw`X-WR-RELCALID:shift@shift2bikes.org`, +...shiftHeader, String.raw`END:VCALENDAR`, "" // trailing new line. i think. ].join("\r\n"); - - -const cancelledDay = [ -String.raw`BEGIN:VCALENDAR`, -String.raw`VERSION:2.0`, -String.raw`PRODID:-//shift2bikes.org//NONSGML shiftcal v2.1//EN`, -String.raw`METHOD:PUBLISH`, -String.raw`X-WR-CALNAME:Shift Bike Calendar`, -String.raw`X-WR-CALDESC:Find fun bike events and make new friends!`, -String.raw`X-WR-RELCALID:shift@shift2bikes.org`, +const canceled1= [ String.raw`BEGIN:VEVENT`, String.raw`UID:event-201@shift2bikes.org`, String.raw`SUMMARY:CANCELLED: ride 2 title`, @@ -224,29 +243,54 @@ String.raw` magna sit ipsum duis elit.\ntime details\nEnds at location\; `, String.raw` end.\nhttp://localhost:3080/calendar/event-201`, String.raw`LOCATION:location\, name.\n
\nlocation && details`, String.raw`STATUS:CANCELLED`, -String.raw`DTSTART:20020801T190000Z`, -String.raw`DTEND:20020801T200000Z`, +String.raw`DTSTART:20020802T020000Z`, +String.raw`DTEND:20020802T030000Z`, String.raw`CREATED:19930728T213900Z`, String.raw`DTSTAMP:19930828T090700Z`, String.raw`SEQUENCE:2`, String.raw`URL:http://localhost:3080/calendar/event-201`, String.raw`END:VEVENT`, +]; + +const cancelledDay = [ +String.raw`BEGIN:VCALENDAR`, +...shiftHeader, +...canceled1, +...event2, +String.raw`END:VCALENDAR`, +"" // trailing new line. i think. +].join("\r\n"); + +const pedalEnd = [ String.raw`BEGIN:VEVENT`, -String.raw`UID:event-202@shift2bikes.org`, -String.raw`SUMMARY:ride 2 title`, -String.raw`CONTACT:organizer`, -String.raw`DESCRIPTION:news flash\nQuis ex cupidatat pariatur cillum pariatur esse id`, -String.raw` magna sit ipsum duis elit.\ntime details\nEnds at location\; `, -String.raw` end.\nhttp://localhost:3080/calendar/event-202`, -String.raw`LOCATION:location\, name.\n\nlocation && details`, +String.raw`UID:pedalpalooza-2002-end@shift2bikes.org`, +String.raw`SUMMARY:Pedalpalooza 2002 is over!`, +String.raw`CONTACT:bikecal@shift2bikes.org`, +String.raw`DESCRIPTION:We hope you've had a great bike summer and a great `, +String.raw` Pedalpalooza!!!\nWhile Pedalpalooza is done\, there is still plenty of `, +String.raw` bike fun to be found on the Shift2bikes website. And you can also `, +String.raw` subscribe to the Shift community calendar to see those rides.\nVisit `, +String.raw` https://shift2bikes.org/calendar/ for more details.`, +String.raw`LOCATION:Portland\, and beyond!`, String.raw`STATUS:CONFIRMED`, -String.raw`DTSTART:20020802T190000Z`, -String.raw`DTEND:20020802T200000Z`, -String.raw`CREATED:19930728T213900Z`, -String.raw`DTSTAMP:19930828T090700Z`, -String.raw`SEQUENCE:2`, -String.raw`URL:http://localhost:3080/calendar/event-202`, +// midnight on the last day +String.raw`DTSTART:20020803T070000Z`, +// the end of the next day +String.raw`DTEND:20020804T065959Z`, +// fake created date ( in the same year ) +String.raw`CREATED:20020601T070000Z`, +String.raw`DTSTAMP:20020601T070000Z`, +String.raw`SEQUENCE:1`, +String.raw`URL:https://shift2bikes.org/calendar/`, String.raw`END:VEVENT`, +]; + +const pedalpaloozaFeed = [ +String.raw`BEGIN:VCALENDAR`, +...pedalpHeader, +...event1, +...event2, +...pedalEnd, String.raw`END:VCALENDAR`, "" // trailing new line. i think. ].join("\r\n"); diff --git a/app/views/ical.njk b/app/views/ical.njk index 33c36c98..01e1c373 100644 --- a/app/views/ical.njk +++ b/app/views/ical.njk @@ -8,15 +8,15 @@ X-WR-RELCALID:{{cal.guid|safe}} {% for evt in events -%} BEGIN:VEVENT UID:{{evt.uid|safe}} -{{evt.summary|safe}} -{{evt.contact|safe}} -{{evt.description|safe}} -{{evt.location|safe}} +{{evt.summary|escapeBreak("SUMMARY")|safe}} +{{evt.contact|escapeBreak("CONTACT")|safe}} +{{evt.description|escapeBreak("DESCRIPTION")|safe}} +{{evt.location|escapeBreak("LOCATION")|safe}} STATUS:{{evt.status|safe}} -DTSTART:{{evt.start|safe}} -DTEND:{{evt.end|safe}} -CREATED:{{evt.created|safe}} -DTSTAMP:{{evt.modified|safe}} +DTSTART:{{evt.start|ical}} +DTEND:{{evt.end|ical}} +CREATED:{{evt.created|ical}} +DTSTAMP:{{evt.modified|ical}} SEQUENCE:{{evt.sequence|safe}} URL:{{evt.url|safe}} END:VEVENT diff --git a/netlify.toml b/netlify.toml index 1bdbdb9f..46de6fc4 100644 --- a/netlify.toml +++ b/netlify.toml @@ -101,13 +101,20 @@ status = 200 force = true -# second URL for pedalpalooza feed that may work better in some clients (cf https://github.com/shift-org/shift-docs/issues/116#issuecomment-1101534820) +# the official pedalpalooza feed [[redirects]] from = "/cal/pedalpalooza-calendar.php" to = "https://api.shift2bikes.org/api/pedalpalooza-calendar.php" status = 200 force = true +# the official "all events" feed. +[[redirects]] + from = "/cal/shift-calendar.php" + to = "https://api.shift2bikes.org/api/shift-calendar.php" + status = 200 + force = true + #------------------------------------------------------------------------------------ # these two are needed for legacy "shareable links" from 2017-early 2019 era #------------------------------------------------------------------------------------ diff --git a/services/nginx/conf.d/shift.conf b/services/nginx/conf.d/shift.conf index 9cc1d916..9b191060 100644 --- a/services/nginx/conf.d/shift.conf +++ b/services/nginx/conf.d/shift.conf @@ -30,13 +30,14 @@ server { # ( see https://docs.docker.com/compose/networking/ ) # ----------------------------------------------- - # used for the per-ride "export link" + # used for the per-ride "export link". + # the client sends an id as a query parameter. location = /api/ics.php { proxy_pass http://node:3080/api/ical.php; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } - # used as a webcal endpoint for calendar subscriptions: + # webcal endpoint for only pedalpalooza subscriptions: # ex. webcal://www.shift2bikes.org/cal/pedalpalooza-calendar.php location = /api/pedalpalooza-calendar.php { # set to a constant range for this year's pedalp @@ -44,6 +45,12 @@ server { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } + # webcal endpoint for that includes all shift events: + location = /api/shift-calendar.php { + proxy_pass http://node:3080/api/ical.php; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + } + # note: app/app.js remaps incoming ".php" extensions to ".js" endpoints location /api/ { # note the trailing slash on the proxy; that causes nginx to strip /api/ completely. diff --git a/site/content/pages/calendar-faq.md b/site/content/pages/calendar-faq.md index d2ee19fe..8d97f25b 100644 --- a/site/content/pages/calendar-faq.md +++ b/site/content/pages/calendar-faq.md @@ -99,29 +99,32 @@ Email the [Shift calendar crew](mailto:bikecal@shift2bikes.org) if you need help ## Subscribing to the calendar -The Pedalpalooza events can be added to your computer or smart phone's calendar. As new rides are created, they will show up on your calendar automatically (usually within 24 hours of the ride being posted by the organizer). This is a convenient way to stay up to date, but remember these are community driven events: any and all events will be visible when you view your calendar. +You can use the following steps to see bike events on your phone or computer's built-in calendar app. As new rides are created, they will show up on your calendar automatically (usually within 24 hours of the ride being posted by the organizer). This is a convenient way to stay up to date, but remember these are community led events: you will see all sorts of rides intended for all different riders. -On many devices, clicking this link — [webcal://www.shift2bikes.org/cal/pedalpalooza-calendar.php](webcal://www.shift2bikes.org/cal/pedalpalooza-calendar.php) — will open your calendar app automatically, and the app will guide you through the steps to subscribe. +There are are two calendars you can subscribe to: -For Android devices (or for Google Calendar on iOS devices) you will have to subscribe manually. +* **The Pedalpalooza calendar**: This will show only the events happening during the official Pedalpalooza summer festival. [webcal://www.shift2bikes.org/cal/pedalpalooza-calendar.php](webcal://www.shift2bikes.org/cal/pedalpalooza-calendar.php) + +* **The Community calendar**: This will show all Pedalpalooza events, but also any other bike rides that might happen over the course of year. [webcal://www.shift2bikes.org/cal/shift-calendar.php](webcal://www.shift2bikes.org/cal/shift-calendar.php) + +On many devices, clicking on either of those links will open your calendar app automatically. Your app will then guide you through the steps to subscribe. For Android devices (or for Google Calendar on iOS devices) you will need to follow these instructions. ### Android and Google Calendar: -1. Copy the Pedalpalooza calendar link above. (On Android, by pressing and holding the link until the "Copy link address" menu appears, and then selecting that option.) +1. Copy one of the calendar links above. (On Android, by pressing and holding the link until the "Copy link address" menu appears, and then selecting that option.) 2. Visit [Google Calendar](https://calendar.google.com/calendar/u/0/r/settings/addbyurl) and if asked log into your Google account. 3. On that Google Calendar page, paste the link you copied into the "URL of Calendar" box (press on that box and hold until the "Paste" menu appears, then choose that option). -4. Finally, click the "Add calendar" button. -5. 🎉 (Now find a ride you like, and bike on over!) +4. Finally, click the "Add calendar" button. +5. 🎉 ( Now find a ride you like, and bike on over! ) For additional help with Android and Google Calendar, please see this [Google support page](https://support.google.com/calendar/answer/37100) under "Use a link to add a public calendar." ### Other common cases: -1. [iPhone Calendar App](https://support.apple.com/guide/iphone/use-multiple-calendars-iph3d1110d4/ios) - use the instructions under "Set up a calendar: _Subscribe to an external, read-only calendar._" +1. [iPhone Calendar App](https://support.apple.com/guide/iphone/use-multiple-calendars-iph3d1110d4/ios) - use the instructions under "Set up a calendar: Subscribe to an external, read-only calendar." 1. [Mac Mail](https://support.apple.com/guide/calendar/subscribe-to-calendars-icl1022/mac) - follow the instructions for "Subscribe to a calendar." 1. [Outlook](https://support.microsoft.com/en-us/office/import-or-subscribe-to-a-calendar-in-outlook-on-the-web-503ffaf6-7b86-44fe-8dd6-8099d95f38df) - follow the instructions for "Subscribing to a calendar." -1. [Thunderbird](https://support.mozilla.org/en-US/kb/adding-a-holiday-calendar) - follow the instructions for subscribing to an "internet holiday calendar", but use the pedalpalooza calendar link from above. ( Pedalpalooza is basically a holiday anyway, right? 😊 ) - +1. [Thunderbird](https://support.mozilla.org/en-US/kb/adding-a-holiday-calendar) - follow the instructions for subscribing to an "internet holiday calendar", substituting one of the calendar links from above. ( Pedalpalooza is basically a holiday anyway, right? 😊 ) ## Glossary diff --git a/site/themes/s2b_hugo_theme/layouts/calevents/single.html b/site/themes/s2b_hugo_theme/layouts/calevents/single.html index 215de936..542acff4 100644 --- a/site/themes/s2b_hugo_theme/layouts/calevents/single.html +++ b/site/themes/s2b_hugo_theme/layouts/calevents/single.html @@ -40,9 +40,13 @@ {{ .Content }} + {{ if not .Params.pp }} + {{ partial "cal/shift-feed.html" . }} + {{ end }} + - {{ if isset .Params "pp" }} + {{ if .Params.pp }} {{ partial "cal/pp-feed.html" . }} {{ end }} diff --git a/site/themes/s2b_hugo_theme/layouts/partials/cal/pp-feed.html b/site/themes/s2b_hugo_theme/layouts/partials/cal/pp-feed.html index 674dea99..e3251000 100644 --- a/site/themes/s2b_hugo_theme/layouts/partials/cal/pp-feed.html +++ b/site/themes/s2b_hugo_theme/layouts/partials/cal/pp-feed.html @@ -1,5 +1,6 @@ +{{/* see also shift-feed.html */}}Want to see rides using your computer or phone's calendar app?
- -If that doesn't open your calendar app, see other ways to subscribe to the calendar feed.
+ +If that doesn't open your calendar app, see other ways to subscribe to the calendar.
Want to see rides using your computer or phone's calendar app?
+ +If that doesn't open your calendar app, see other ways to subscribe to the calendar.
+