From 771f10958fb07f64c03b2df0d7fbc5a5dd79ce11 Mon Sep 17 00:00:00 2001 From: Nick Cappadona Date: Fri, 14 Sep 2018 14:00:20 -0400 Subject: [PATCH] Mann reservable lcd (#73) * First go at study room availability for LCD Using two tiers of mocked data: * first result set is hardcoded into a mocked components * second result set is loading a mocked JSON file based on actual LibCal API response Very much a work in progress. * Iterate bookings, add available slots btw & after First pass at this. Not accounting for leading available slots since we're still working with mock data and unsure whether the API drops/excludes bookings once current time is greater than the `toDate`. Early testing indicates this is not the case (any booking with a datetime for the requested date remains), which will mean more logic needs to be introduced to handle open and current availability. * Update data mock based on LibCal test reservations * Refactor & simplify logic for room availability Leaning heavily on lodash chaining [1] to filter and map the original bookings array returned by LibCal API. Big hat tip to Pete from this SO thread [2]. [1] https://blog.mariusschulz.com/2015/05/14/implicit-function-chains-in-lodash [2] https://codereview.stackexchange.com/questions/82596/lodash-chain-implementation * Leading padding for first booking when necessary While implementing this, realized that the filter for removing past bookings needs to be moved to the end of the chain, after the logic for determining availability padding. * Enforce open/close times Can't hurt to double check. Don't assume that LibCal spaces API returns bookings that abide to current/correct hours. * Overhaul opening/closing handling * Ensure moment object is returned * Split out check for early morning closing (DRY) * Dedicated function to format time for display * Down with this keyword * Note to self: DRY variables & util styles * Drop mock study room component Served admirably... * Refactor to store space schedules in Vuex Mocked data is no longer needed, so drop it along with baggage. Only fetching data via LibCal API on initial load for now (no interval). TODO: Use dotenv to handle sensitive LibCal oAuth data * Note to self about unique bookId for avail slots * Generalize dynamic routing Allow for more flexibility to better accommodate future use cases with minimal effort. * Move _location (aka library) to first segment * Pass category as route param for spaces > For now, moving Mann signs (consultation desks and study rooms) but > leaving OKU untouched to avoid downtime. TODO: coordinate new URLs > with Olin & Uris circ at later date. * Extract LibCal schema & generalize hour functions First step in cleaning house and modularizing LibCal utils. Potential to eventually extract into separate package. Also generalize the hours related functions to use `location` in place of 'desk'. Still a bit awkward with passing of the `isDesk` param to handle different lookup in the schema, but wanted to keep the consultation desks grouped together. Most likely will revisit this down the road. * Generalize component & requested spaces as param No longer hardcoded for Mann study rooms. * Prime store with requested spaces Necessary since LibCal returns empty array if no bookings exist. * Generalize hours store for desks & spaces Still a bit of a work in progress in terms of: * logic for space open/close * assumption that all requested spaces follow same hours (applicable for the Mann reservable study room pilot) * Use location for retrieving LibCal space bookings Still a work in progress. Need to fine tune the filtering (down to requested spaces). Also started on simplifying opening/closing via hours store, but needs work. * Filter LibCal bookings to requested spaces * Keep LibCal API creds secret All together now... * dotenv [1] * axios module [2] * proxy module [3] * serverMiddleware [4] * body-parser [5] Go team. Heavily based on modify-post example [6] from http-proxy-middleware. The need to restream the parsed body prior to proxying [7 - 9] was a special treat. [1] https://github.com/motdotla/dotenv [2] https://axios.nuxtjs.org/ [3] https://github.com/nuxt-community/proxy-module [4] https://nuxtjs.org/api/configuration-servermiddleware [5] https://github.com/expressjs/body-parser [6] https://github.com/chimurai/http-proxy-middleware/blob/master/recipes/modify-post.md [7] https://github.com/chimurai/http-proxy-middleware/issues/40#issuecomment-249430255 [8] https://github.com/nodejitsu/node-http-proxy/pull/1027 [9] https://github.com/nodejitsu/node-http-proxy/pull/1264 * Extract restream function from proxy config Quick refactor in attempt to maintain overall clarity for Nuxt config. * Closed status with next opening time Rename existing `formatFutureOpening()` used for consultation signs to `formatStatusChange()`. * Remove jsonp-promise No longer needed now since CORS is a non issue thanks to proxying implemented in cc90db6. All axios all the time. > jsonp-promise first introduced via 41 * Sync/update for spaces sign via setInterval * time every 10s * hours every 30s * reservations every 1m * Fix lodash import for libcal util Doh...cherry-picking on the import was not intended here. Was causing trouble when fetching reservations from the client via setInterval. Specifically when attempting to build the schedule using, encountering `undefined` errors for the lodash methods. * Available til closing for spaces with no schedule * Reservation URL in the footer #72 * FA icons to indicate indiv vs group rooms Quick-n-dirty for now with Font Awesome 5.2 via CDN. WIll revisit to: * Use packages & official Vue.js components * Clean up logic for determining which icon applies for each space * Account for early AM closings within nextOpen() Doh. Never encountered this with the consultation desks but today with the Fall semester in full swing, moment's isBetween() was always returning false for reservable study spaces. Probably should consider refactoring the closingTime() method to pass hours as optional argument instead of always fetching them from the LibCal API. This would allow further DRYing. * Https for reserve link * Quick-n-dirty font scaling for launch Go time...will be revisited. * Drop lightweight font As per feedback from working group. Chromebox is having difficulty rendering lightweight Lato. Keeping it for meridiem on block start time since it appears to be legible on the photos sent by the group. * Bump limit on LibCal bookings API to 100 (max) Default is 20. Noticed that a reservation for 270 was dropped from the sign during the day. While bumping to the max provides us some leeway, we will have to keep our eye on this and see if we still hit the ceiling during peak semester activity. * Display 12am as midnight * Reserve link to top, consistent wght for datetime And more breathing room for header. Go team! * Sort each space's reservation by start time Not sure if something changed in LibCal's API response, but this hadn't been necessary until noticing the issue today. * Unique ID for available time slots Via unique-string package. * Improve test for empty schedule There are occasions when the object exists but the schedule array is empty due to the fact that the API returns all bookings (even canceled by either party) and only those bookings with a `confirmed` status remain after filtering. * Remove debugging holdouts & rename bookingsParser --- .env.example | 5 + .gitignore | 3 + components/SpaceAvailability.vue | 130 ++++++++++ data-mocks/libcal-space-avail.json | 74 ++++++ data-mocks/libcal-studyroom-bookings.json | 146 +++++++++++ nuxt.config.js | 53 ++++ package.json | 6 +- pages/{mann => _location}/consult/_desk.vue | 24 +- pages/_location/spaces/_category.vue | 133 ++++++++++ store/{consultDesk.js => hours.js} | 23 +- store/spaces.js | 28 ++ utils/libcal-schema.js | 94 +++++++ utils/libcal.js | 273 +++++++++++++------- yarn.lock | 69 ++++- 14 files changed, 937 insertions(+), 124 deletions(-) create mode 100644 .env.example create mode 100644 components/SpaceAvailability.vue create mode 100644 data-mocks/libcal-space-avail.json create mode 100644 data-mocks/libcal-studyroom-bookings.json rename pages/{mann => _location}/consult/_desk.vue (82%) create mode 100644 pages/_location/spaces/_category.vue rename store/{consultDesk.js => hours.js} (60%) create mode 100644 store/spaces.js create mode 100644 utils/libcal-schema.js diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..b98b1a9 --- /dev/null +++ b/.env.example @@ -0,0 +1,5 @@ +# Copy this file to .env before editing any values + +# LibCal API 1.1 credentials +LIBCAL_CLIENT_ID=CHANGEME +LIBCAL_CLIENT_SECRET=CHANGEME diff --git a/.gitignore b/.gitignore index 7ee6115..5a99723 100644 --- a/.gitignore +++ b/.gitignore @@ -9,3 +9,6 @@ npm-debug.log # Nuxt generate dist + +# Environment variables via dotenv +.env diff --git a/components/SpaceAvailability.vue b/components/SpaceAvailability.vue new file mode 100644 index 0000000..709ea68 --- /dev/null +++ b/components/SpaceAvailability.vue @@ -0,0 +1,130 @@ + + + + + diff --git a/data-mocks/libcal-space-avail.json b/data-mocks/libcal-space-avail.json new file mode 100644 index 0000000..bea17d1 --- /dev/null +++ b/data-mocks/libcal-space-avail.json @@ -0,0 +1,74 @@ +[ + { + "bookId": "cs_d49ELk2", + "eid": 20087, + "cid": 5395, + "lid": 2939, + "fromDate": "2018-03-20T08:00:00-04:00", + "toDate": "2018-03-20T11:00:00-04:00", + "firstName": "Chris", + "lastName": "Terreri", + "email": "terreri@nhl.com", + "status": "Confirmed" + }, + { + "bookId": "cs_vp92j77", + "eid": 20087, + "cid": 5395, + "lid": 2939, + "fromDate": "2018-03-20T11:00:00-04:00", + "toDate": "2018-03-20T15:00:00-04:00", + "firstName": "Claude", + "lastName": "Lemieux", + "email": "lemieux@nhl.com", + "status": "Confirmed" + }, + { + "bookId": "cs_m5s8Tyr", + "eid": 20089, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-20T12:00:00-04:00", + "toDate": "2018-03-20T16:00:00-04:00", + "firstName": "Scott", + "lastName": "Niedermayer", + "email": "niedermayer@nhl.com", + "status": "Confirmed" + }, + { + "bookId": "cs_1OZO2ir", + "eid": 20088, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-20T18:00:00-04:00", + "toDate": "2018-03-20T20:00:00-04:00", + "firstName": "Scott", + "lastName": "Stevens", + "email": "stevens@nhl.com", + "status": "Confirmed" + }, + { + "bookId": "cs_kwMreUK", + "eid": 20089, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-20T19:00:00-04:00", + "toDate": "2018-03-20T21:00:00-04:00", + "firstName": "Martin", + "lastName": "Brodeur", + "email": "nick.cappadona@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_1ji9Rgp", + "eid": 20088, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-20T22:00:00-04:00", + "toDate": "2018-03-21T00:00:00-04:00", + "firstName": "Patrick", + "lastName": "Elias", + "email": "elias@nhl.com", + "status": "Confirmed" + } +] diff --git a/data-mocks/libcal-studyroom-bookings.json b/data-mocks/libcal-studyroom-bookings.json new file mode 100644 index 0000000..82fa957 --- /dev/null +++ b/data-mocks/libcal-studyroom-bookings.json @@ -0,0 +1,146 @@ +[ + { + "bookId": "cs_XnM9XSd", + "eid": 20089, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-26T22:00:00-04:00", + "toDate": "2018-03-27T00:00:00-04:00", + "firstName": "Nick", + "lastName": "Cappadona", + "email": "nick.cappadona@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_dvrZBU8", + "eid": 20088, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-27T08:00:00-04:00", + "toDate": "2018-03-27T09:00:00-04:00", + "firstName": "Nick", + "lastName": "Cappadona", + "email": "nick.cappadona@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_x1w6mf2", + "eid": 20089, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-27T09:00:00-04:00", + "toDate": "2018-03-27T10:00:00-04:00", + "firstName": "Ella", + "lastName": "Fitzgerald", + "email": "nac26@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_zxnYPfO", + "eid": 20087, + "cid": 5395, + "lid": 3182, + "fromDate": "2018-03-27T09:00:00-04:00", + "toDate": "2018-03-27T11:00:00-04:00", + "firstName": "Bobby", + "lastName": "Bland", + "email": "nac26@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_G0L2Vij", + "eid": 20088, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-27T10:00:00-04:00", + "toDate": "2018-03-27T13:00:00-04:00", + "firstName": "Charles", + "lastName": "Mingus", + "email": "nac26@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_1NJ6esr", + "eid": 20089, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-27T14:00:00-04:00", + "toDate": "2018-03-27T15:00:00-04:00", + "firstName": "Esperanza", + "lastName": "Spalding", + "email": "nickypasta@gmail.com", + "status": "Confirmed" + }, + { + "bookId": "cs_AaVk3f6", + "eid": 20087, + "cid": 5395, + "lid": 3182, + "fromDate": "2018-03-27T14:00:00-04:00", + "toDate": "2018-03-27T16:00:00-04:00", + "firstName": "Cyrus", + "lastName": "Chestnut", + "email": "nac26@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_9GEaOSB", + "eid": 20088, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-27T16:00:00-04:00", + "toDate": "2018-03-27T19:00:00-04:00", + "firstName": "Art", + "lastName": "Blakey", + "email": "nickypasta@gmail.com", + "status": "Confirmed" + }, + { + "bookId": "cs_Pz6JbfJ", + "eid": 20087, + "cid": 5395, + "lid": 3182, + "fromDate": "2018-03-27T18:00:00-04:00", + "toDate": "2018-03-27T19:00:00-04:00", + "firstName": "Jaco", + "lastName": "Pastorius", + "email": "nick.cappadona@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_W1OXYHP", + "eid": 20087, + "cid": 5395, + "lid": 3182, + "fromDate": "2018-03-27T20:00:00-04:00", + "toDate": "2018-03-27T21:00:00-04:00", + "firstName": "Quincy", + "lastName": "Jones", + "email": "nick.cappadona@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_mVdBbF3", + "eid": 20089, + "cid": 5396, + "lid": 3182, + "fromDate": "2018-03-27T21:00:00-04:00", + "toDate": "2018-03-28T00:00:00-04:00", + "firstName": "Nina", + "lastName": "Simone", + "email": "nick.cappadona@cornell.edu", + "status": "Confirmed" + }, + { + "bookId": "cs_p3A0KUk", + "eid": 20087, + "cid": 5395, + "lid": 3182, + "fromDate": "2018-03-27T22:00:00-04:00", + "toDate": "2018-03-28T00:00:00-04:00", + "firstName": "Shemekia", + "lastName": "Copeland", + "email": "nick.cappadona@cornell.edu", + "status": "Confirmed" + } +] diff --git a/nuxt.config.js b/nuxt.config.js index 16761fe..0023ba1 100644 --- a/nuxt.config.js +++ b/nuxt.config.js @@ -1,7 +1,60 @@ +// Use .env in nuxt config only (keep secrets on the dl) +// -- https://github.com/nuxt/nuxt.js/issues/2033#issuecomment-398820574 +// -- https://github.com/nuxt-community/dotenv-module#using-env-file-in-nuxtconfigjs +require('dotenv').config() + +// Body parser middleware needed to inject required fields for LibCal API auth +const bodyParser = require('body-parser') + +const libcalApi = 'https://api2.libcal.com' +const libcalApiPath = '/api/libcal/' +const libcalHoursApi = 'https://api3.libcal.com' +const libcalHoursApiPath = '/api/libcal-hours/' + +var restreamClientCreds = (proxyReq, req, res) => { + if (req.method === 'POST' && req.body) { + // Build object for POST request to obtain access token + let body = { + client_id: process.env.LIBCAL_CLIENT_ID, + client_secret: process.env.LIBCAL_CLIENT_SECRET, + grant_type: 'client_credentials' + } + + body = JSON.stringify(body) + + // Update headers + proxyReq.setHeader('Content-Type', 'application/json') + proxyReq.setHeader('Content-Length', Buffer.byteLength(body)) + + // Write new body to the proxyReq stream + proxyReq.write(body) + } +} + module.exports = { modules: [ '@nuxtjs/axios' ], + serverMiddleware: [ + // Parse request body so it can be manipulated by proxy middleware + // -- https://nuxtjs.org/api/configuration-servermiddleware + { path: libcalApiPath, handler: bodyParser.json() } + ], + axios: { + prefix: '/api/', + proxy: true + }, + proxy: { + [libcalApiPath]: { + target: libcalApi, + pathRewrite: { [`^${libcalApiPath}`]: '' }, + onProxyReq: restreamClientCreds + }, + [libcalHoursApiPath]: { + target: libcalHoursApi, + pathRewrite: { [`^${libcalHoursApiPath}`]: '' } + } + }, /* ** Headers of the page */ diff --git a/package.json b/package.json index 08e7813..8f29fde 100644 --- a/package.json +++ b/package.json @@ -12,9 +12,11 @@ }, "dependencies": { "@nuxtjs/axios": "^5.1.1", - "jsonp-promise": "^0.1.2", + "body-parser": "^1.18.3", + "dotenv": "^6.0.0", "lodash": "^4.17.4", - "nuxt": "^1.4.0" + "nuxt": "^1.4.0", + "unique-string": "^1.0.0" }, "scripts": { "dev": "nuxt", diff --git a/pages/mann/consult/_desk.vue b/pages/_location/consult/_desk.vue similarity index 82% rename from pages/mann/consult/_desk.vue rename to pages/_location/consult/_desk.vue index 4565ff9..cc8363e 100644 --- a/pages/mann/consult/_desk.vue +++ b/pages/_location/consult/_desk.vue @@ -2,10 +2,10 @@

{{ desk.replace('-', ' ') }}

-

{{ deskInfo.description }}

+

{{ hours.description }}

- {{ deskInfo.status }} until + {{ hours.status }} until
@@ -33,28 +33,28 @@ export default { } }, computed: { - deskInfo () { - return this.$store.state.consultDesk + hours () { + return this.$store.state.hours }, relativeStatusChange () { - return Robin.formatFutureOpening(this.deskInfo.statusChange) + return Robin.formatStatusChange(this.hours.statusChange) }, statusClass () { - return 'status--' + this.deskInfo.status.replace(/\s/g, '-') + return 'status--' + this.hours.status.replace(/\s/g, '-') } }, async fetch ({ store, params }) { - await store.dispatch('consultDesk/fetchStatus', { - desk: params.desk, - jsonp: false + await store.dispatch('hours/fetch', { + location: params.desk, + desk: true }) }, mounted () { // Update desk status every 30 seconds setInterval(() => { - this.$store.dispatch('consultDesk/fetchStatus', { - desk: this.$route.params.desk, - jsonp: true + this.$store.dispatch('hours/fetch', { + location: this.$route.params.desk, + desk: true }) }, 1000 * 30) } diff --git a/pages/_location/spaces/_category.vue b/pages/_location/spaces/_category.vue new file mode 100644 index 0000000..fa778ef --- /dev/null +++ b/pages/_location/spaces/_category.vue @@ -0,0 +1,133 @@ + + + + + diff --git a/store/consultDesk.js b/store/hours.js similarity index 60% rename from store/consultDesk.js rename to store/hours.js index f8cb147..e619522 100644 --- a/store/consultDesk.js +++ b/store/hours.js @@ -1,4 +1,5 @@ import { assign, isEmpty } from 'lodash' +import api from '~/utils/libcal-schema' import Robin from '~/utils/libcal' export const state = () => ({ @@ -11,35 +12,37 @@ export const mutations = { } export const actions = { - async fetchStatus ({ commit, state }, payload) { + async fetch ({ commit, state }, payload) { // Fetch from LibCal API under any of these scenarios // -- a. initial request (empty Vuex store) // -- b. cache has expired // -- c. the stored change in status has past // -- d. the clock has struck midnight (we've crossed over to the next day) if (isEmpty(state) || Robin.staleCache(state.updated) || Robin.pastChange(state.statusChange) || Robin.nextDay(state.updated)) { - let feed = await Robin.getHours(this.$axios, payload.desk, undefined, payload.jsonp) - - // Use bullet delimiter for multi-item description - let description = Robin.api.desks[payload.desk].description.join(' \u2022 ') + let isDesk = typeof payload.desk === 'undefined' ? false : payload.desk + let feed = await Robin.getHours(this.$axios, payload.location, undefined, isDesk) const libcalStatus = feed.locations[0].times.status const allHours = typeof feed.locations[0].times.hours === 'undefined' ? null : feed.locations[0].times.hours - const status = await Robin.openNow(this.$axios, payload.desk, libcalStatus, allHours, payload.jsonp) + const status = await Robin.openNow(this.$axios, payload.location, libcalStatus, allHours, isDesk) // Relabel status under certain circumstances - let statusLabel = Robin.statusLabel(payload.desk, status.current) + let statusLabel = Robin.statusLabel(payload.location, status.current) - const deskData = { + const hoursData = { 'name': feed.locations[0].name, - 'description': description, 'hours': allHours, 'status': statusLabel, 'statusChange': status.change, 'updated': status.timestamp } - commit('update', deskData) + if (isDesk) { + // Use bullet delimiter for multi-item description + hoursData['description'] = api.desks[payload.location].description.join(' \u2022 ') + } + + commit('update', hoursData) } } } diff --git a/store/spaces.js b/store/spaces.js new file mode 100644 index 0000000..8735e0a --- /dev/null +++ b/store/spaces.js @@ -0,0 +1,28 @@ +import { assign, isEmpty } from 'lodash' +import Robin from '~/utils/libcal' + +export const state = () => ({ +}) + +export const mutations = { + prime: (state, data) => data.forEach(d => (state[d.name] = {'id': d.id})), + update: (state, data) => assign(state, data) +} + +export const actions = { + async fetchSchedule ({ commit, state }, payload) { + // Fetch from LibCal API under any of these scenarios + // -- a. initial request (empty Vuex store) + // -- b. cache has expired + // -- c. the stored change in status has past + // -- d. the clock has struck midnight (we've crossed over to the next day) + if (isEmpty(state) || Robin.staleCache(state.updated) || Robin.pastChange(state.statusChange) || Robin.nextDay(state.updated)) { + let feed = await Robin.getReservations(this.$axios, payload.location) + + const schedule = Robin.buildSchedule(feed, payload.location, state, await Robin.openingTime(this.$axios, payload.location), await Robin.closingTime(this.$axios, payload.location)) + + commit('update', schedule) + } + }, + primeSpaces: ({ commit }, spaces) => commit('prime', spaces) +} diff --git a/utils/libcal-schema.js b/utils/libcal-schema.js new file mode 100644 index 0000000..2d3b1f8 --- /dev/null +++ b/utils/libcal-schema.js @@ -0,0 +1,94 @@ +export default { + endpoints: { + auth: 'libcal/1.1/oauth/token', + hours: 'libcal-hours/api_hours_date.php?iid=973&format=json&nocache=1&lid=', + spaces: { + bookings: 'libcal/1.1/space/bookings?limit=100' + } + }, + desks: { + career: { + hoursId: 7733, + description: [ + 'CALS student services' + ] + }, + ciser: { + hoursId: 3016, + description: [] + }, + cscu: { + hoursId: 3017, + description: [ + 'statistical consulting' + ] + }, + 'cu-career': { + hoursId: 7734, + description: [ + 'Graduate', + 'Cornell Career Services' + ] + }, + elso: { + hoursId: 7701, + description: [ + 'english language support' + ] + }, + gis: { + hoursId: 2204, + description: [] + }, + gws: { + hoursId: 3303, + description: [ + 'writing', + 'grad', + 'appt only' + ] + }, + knight: { + hoursId: 3018, + description: [ + 'writing', + 'walk-in' + ] + }, + rdmsg: { + hoursId: 3302, + description: [ + 'research data management' + ] + }, + reference: { + hoursId: 1710, + description: [] + } + }, + locations: { + mann: { + id: 96, + hoursId: 1707, + categories: { + studyrooms: { + id: false, + spaces: [ + { + id: 20087, + room: 270 + }, + { + id: 20088, + room: 271 + }, + { + id: 20089, + room: 272 + } + ] + } + } + } + } +} diff --git a/utils/libcal.js b/utils/libcal.js index 1c975e6..679e7a0 100644 --- a/utils/libcal.js +++ b/utils/libcal.js @@ -1,7 +1,7 @@ +import api from '~/utils/libcal-schema' +import _ from 'lodash' import moment from 'moment' -import jsonpPromise from 'jsonp-promise' - -const baseUrl = 'https://api3.libcal.com/' +import uniqueString from 'unique-string' // Set formatting strings for Moment's calendar method // http://momentjs.com/docs/#/customization/calendar @@ -19,113 +19,163 @@ moment.updateLocale('en', { } }) -export default { - api: { - endpoints: { - hours: baseUrl + 'api_hours_date.php?iid=973&format=json&nocache=1&lid=' - }, - desks: { - career: { - id: 7733, - description: [ - 'CALS student services' - ] - }, - ciser: { - id: 3016, - description: [] - }, - cscu: { - id: 3017, - description: [ - 'statistical consulting' - ] - }, - 'cu-career': { - id: 7734, - description: [ - 'Graduate', - 'Cornell Career Services' - ] - }, - elso: { - id: 7701, - description: [ - 'english language support' - ] - }, - gis: { - id: 2204, - description: [] - }, - gws: { - id: 3303, - description: [ - 'writing', - 'grad', - 'appt only' - ] - }, - knight: { - id: 3018, - description: [ - 'writing', - 'walk-in' - ] - }, - rdmsg: { - id: 3302, - description: [ - 'research data management' - ] - }, - reference: { - id: 1710, - description: [] - } - } - }, +const libCal = { timeFormat: 'h:mm a', alreadyClosed: function (hours) { if (hours === null) return false // If multiple openings/closings, compare against last one for the day - const lastClosing = hours.pop().to + let lastClosing = moment(hours.pop().to, libCal.timeFormat) + + lastClosing = libCal.earlyMorningClose(lastClosing) - return moment().isSameOrAfter(moment(lastClosing, this.timeFormat)) + return moment().isSameOrAfter(lastClosing) }, - formatFutureOpening: function (datetime) { - return datetime === null ? 'no upcoming openings' : moment(datetime).calendar() + availableSlot: function (start, end) { + return { + bookId: 'avail_' + uniqueString(), + fromDate: start, + toDate: end, + isAvailable: true, + startTime: libCal.parseDate(start) + } + }, + buildSchedule: (bookings, location, spaces, opening, closing) => { + let schedule = {} + bookings + // Only include reservations for requested spaces + .filter(b => _.includes(Object.values(spaces).map(s => s.id), b.eid)) + // Add schedule object for each space + .forEach(b => { + // Use room name from schema + let name = Object.entries(spaces).find(s => s[1].id === b.eid)[0] + + schedule[name] = { + id: b.eid, + schedule: libCal.bookingsParser(bookings, b.eid, opening, closing) + } + }) + + // Insert 'available until closing' slot for any space with empty schedule + Object.keys(spaces).forEach(s => { + if (typeof schedule[s] === 'undefined' || !schedule[s].schedule.length) { + const availableTilClose = libCal.availableSlot(opening, closing) + availableTilClose.lastUp = true + + schedule[s] = { + id: spaces[s].id, + schedule: [availableTilClose] + } + } + }) + + return schedule + }, + requestedSpaces: (location, category) => { + const spacesInCategory = api.locations[location].categories[category].spaces + let spaces = [] + spacesInCategory + .forEach(s => spaces.push({ 'id': s.id, 'name': s.room })) + if (category === 'studyrooms') spaces.reverse() + return spaces + }, + bookingsParser: function (bookings, room, openingTime, closingTime) { + const roomAvailability = _(bookings) + // Filter bookings by room, status(confirmed), and while open + .filter(function (booking, index, allBookings) { + const confirmed = booking.status === 'Confirmed' + const thisRoom = booking.eid === room + const whileOpen = moment(booking.fromDate).isSameOrAfter(openingTime) && moment(booking.toDate).isSameOrBefore(closingTime) + return thisRoom && + confirmed && + whileOpen + }) + // Sort by start time + .sortBy('fromDate') + // Fill gaps between & pad bookings with available slots + .flatMap(function (booking, index, allBookings) { + const paddedBooking = [booking] + const prevIndex = index - 1 + booking.startTime = libCal.parseDate(booking.fromDate) + + // If first booking & starts after opening, pad before + if (index === 0 && + moment(booking.fromDate).isAfter(openingTime) + ) { + const availableNow = libCal.availableSlot(openingTime, booking.fromDate) + paddedBooking.splice(0, 0, availableNow) + } + // If not back-to-back with previous booking, pad before (aka between) + if (prevIndex > -1 && + !moment(booking.fromDate).isSame(allBookings[prevIndex].toDate) + ) { + const availableSlot = libCal.availableSlot(allBookings[prevIndex].toDate, booking.fromDate) + paddedBooking.splice(0, 0, availableSlot) + } + // If final booking... + if (index + 1 === allBookings.length) { + // Pad after if it falls short of closing + if (moment(booking.toDate).isBefore(moment(closingTime))) { + const availableTilClose = libCal.availableSlot(booking.toDate, closingTime) + availableTilClose.lastUp = true + paddedBooking.push(availableTilClose) + // Otherwise, mark it as running through to closing + } else { + booking.lastUp = true + } + } + return paddedBooking + }) + // Now filter out past bookings + .filter(function (booking, index, allBookings) { + return moment().isSameOrBefore(booking.toDate) + }) + .value() + + return roomAvailability + }, + earlyMorningClose: function (closing) { + return moment(closing, libCal.timeFormat).isBefore(moment('6am', libCal.timeFormat)) ? closing.add(1, 'day') : closing }, formatDate: function (date) { return moment(date).format('Y-MM-DD') }, - getHours: function (axios, desk, date, jsonp = false) { - const requestDate = typeof date === 'undefined' ? '' : '&date=' + this.formatDate(date) - const url = this.api.endpoints.hours + this.api.desks[desk].id + requestDate - - if (jsonp) { - // If fetching updates from client, need to deal with JSONP - // -- LibCal doesn't set Access-Control-Allow-Origin header - return jsonpPromise(url).promise - } else { - // Non-issue when proxied through server on initial load (thanks Nuxt) - return axios.$get(url) - } + formatStatusChange: function (datetime) { + const statusChange = datetime === null ? 'no upcoming openings' : moment(datetime).calendar() + return statusChange === '12:00 am' ? 'Midnight' : statusChange + }, + formatTime: function (date) { + return moment(date).format(libCal.timeFormat) + }, + getHours: function (axios, location, date, isDesk = false) { + const requestDate = typeof date === 'undefined' ? '' : '&date=' + libCal.formatDate(date) + const locId = isDesk ? api.desks[location].hoursId : api.locations[location].hoursId + const url = api.endpoints.hours + locId + requestDate + + return axios.$get(url) + }, + async getReservations (axios, location, date = false) { + const requestDate = date ? '&date=' + libCal.formatDate(date) : '' + const scope = 'lid=' + api.locations[location].id + const url = api.endpoints.spaces.bookings + scope + requestDate + + let authorize = await axios.$post(api.endpoints.auth) + axios.setToken(authorize.access_token, 'Bearer') + + return axios.$get(url) }, nextDay: function (lastUpdated) { return moment().isAfter(moment(lastUpdated), 'd') }, - async nextOpening (axios, desk, jsonp = false) { + async nextOpening (axios, location, isDesk = false) { var bigWinner = null // Check today plus next 14 days for (var i = 0; i < 15; i++) { var dateToCheck = moment().add(i, 'days') - var openingTime = await this.openingTime(axios, desk, this.formatDate(dateToCheck), jsonp) + var openingTime = await libCal.openingTime(axios, location, libCal.formatDate(dateToCheck), isDesk) if (openingTime !== null) { // Use openingTime to update existing moment and set hours & mins - openingTime = moment(openingTime, this.timeFormat) bigWinner = dateToCheck.set({ 'hour': openingTime.get('hour'), 'minute': openingTime.get('minute') @@ -136,22 +186,40 @@ export default { return bigWinner }, - async openingTime (axios, desk, date, jsonp = false) { - let feed = await this.getHours(axios, desk, date, jsonp) + async hoursForDate (axios, location, date, isDesk = false) { + let feed = await libCal.getHours(axios, location, date, isDesk) const hours = typeof feed.locations[0].times.hours === 'undefined' ? null : feed.locations[0].times.hours + return hours + }, + async openingTime (axios, location, date, isDesk = false) { + const hours = await libCal.hoursForDate(axios, location, date, isDesk) + // Copy hours since it gets emptied after using as function param // -- TODO: Consider immutable.js or seamless-immutable const hoursClone = hours !== null ? hours.slice(0) : null // If dealing with today, ensure we're not already closed - if (moment().isSame(moment(date), 'd') && this.alreadyClosed(hoursClone)) { + if (moment().isSame(moment(date), 'd') && libCal.alreadyClosed(hoursClone)) { return null } - return hours !== null ? hours[0].from : null + return hours !== null ? moment(hours[0].from, libCal.timeFormat) : null }, - async openNow (axios, desk, libcalStatus, hours, jsonp = false) { + async closingTime (axios, location, date, isDesk = false) { + const hours = await libCal.hoursForDate(axios, location, date, isDesk) + + let closingTime = hours !== null ? moment(hours[0].to, libCal.timeFormat) : null + + if (closingTime) { + // Account for early morning closings the following day + // -- LibCal only returns time, no date, so add a day to early morning closings for true comparisons + closingTime = libCal.earlyMorningClose(closingTime) + } + + return closingTime + }, + async openNow (axios, location, libcalStatus, hours, isDesk = false) { let status = { current: 'closed', timestamp: moment() // Use for caching results from LibCal API @@ -160,17 +228,20 @@ export default { if (hours) { // Account for potential of multiple openings/closings in a given day const isOpen = hours.find((hoursBlock) => { - return (moment().isBetween(moment(hoursBlock.from, this.timeFormat), moment(hoursBlock.to, this.timeFormat), null, [])) + // Account for early morning closings the following day + // -- LibCal only returns time, no date, so add a day to early morning closings for true comparisons + const closingTime = libCal.earlyMorningClose(moment(hoursBlock.to, libCal.timeFormat)) + return (moment().isBetween(moment(hoursBlock.from, libCal.timeFormat), closingTime, null, [])) }) if (isOpen !== undefined) { status.current = 'open' - status.change = moment(isOpen.to, this.timeFormat) + status.change = moment(isOpen.to, libCal.timeFormat) return status } } - let statusChange = await this.nextOpening(axios, desk, jsonp) + let statusChange = await libCal.nextOpening(axios, location, isDesk) status.change = statusChange @@ -180,6 +251,14 @@ export default { return status }, + parseDate: function (date) { + let startDate = moment(date) + let startTime = {} + startTime.hour = startDate.format('h') + startTime.minute = startDate.format('mm') + startTime.meridiem = startDate.format('a') + return startTime + }, pastChange: function (changeTime) { return moment().isSameOrAfter(moment(changeTime)) }, @@ -203,3 +282,5 @@ export default { return status } } + +export default libCal diff --git a/yarn.lock b/yarn.lock index 2d8b280..c202563 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1044,6 +1044,21 @@ body-parser@1.18.2: raw-body "2.3.2" type-is "~1.6.15" +body-parser@^1.18.3: + version "1.18.3" + resolved "https://registry.yarnpkg.com/body-parser/-/body-parser-1.18.3.tgz#5b292198ffdd553b3a0f20ded0592b956955c8b4" + dependencies: + bytes "3.0.0" + content-type "~1.0.4" + debug "2.6.9" + depd "~1.1.2" + http-errors "~1.6.3" + iconv-lite "0.4.23" + on-finished "~2.3.0" + qs "6.5.2" + raw-body "2.3.3" + type-is "~1.6.16" + boolbase@~1.0.0: version "1.0.0" resolved "https://registry.yarnpkg.com/boolbase/-/boolbase-1.0.0.tgz#68dff5fbe60c51eb37725ea9e3ed310dcc1e776e" @@ -1732,6 +1747,10 @@ crypto-browserify@^3.11.0: randombytes "^2.0.0" randomfill "^1.0.3" +crypto-random-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/crypto-random-string/-/crypto-random-string-1.0.0.tgz#a230f64f568310e1498009940790ec99545bca7e" + css-color-function@~1.3.3: version "1.3.3" resolved "https://registry.yarnpkg.com/css-color-function/-/css-color-function-1.3.3.tgz#8ed24c2c0205073339fafa004bc8c141fccb282e" @@ -2055,6 +2074,10 @@ domutils@1.5.1: dom-serializer "0" domelementtype "1" +dotenv@^6.0.0: + version "6.0.0" + resolved "https://registry.yarnpkg.com/dotenv/-/dotenv-6.0.0.tgz#24e37c041741c5f4b25324958ebbc34bca965935" + duplexer@^0.1.1: version "0.1.1" resolved "https://registry.yarnpkg.com/duplexer/-/duplexer-0.1.1.tgz#ace6ff808c1ce66b57d1ebf97977acb02334cfc1" @@ -3163,6 +3186,15 @@ http-errors@1.6.2, http-errors@~1.6.2: setprototypeof "1.0.3" statuses ">= 1.3.1 < 2" +http-errors@1.6.3, http-errors@~1.6.3: + version "1.6.3" + resolved "https://registry.yarnpkg.com/http-errors/-/http-errors-1.6.3.tgz#8b55680bb4be283a0b5bf4ea2e38580be1d9320d" + dependencies: + depd "~1.1.2" + inherits "2.0.3" + setprototypeof "1.1.0" + statuses ">= 1.4.0 < 2" + http-proxy-middleware@^0.17.4: version "0.17.4" resolved "https://registry.yarnpkg.com/http-proxy-middleware/-/http-proxy-middleware-0.17.4.tgz#642e8848851d66f09d4f124912846dbaeb41b833" @@ -3203,6 +3235,12 @@ iconv-lite@0.4.19, iconv-lite@^0.4.17, iconv-lite@~0.4.13: version "0.4.19" resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.19.tgz#f7468f60135f5e5dad3399c0a81be9a1603a082b" +iconv-lite@0.4.23: + version "0.4.23" + resolved "https://registry.yarnpkg.com/iconv-lite/-/iconv-lite-0.4.23.tgz#297871f63be507adcfbfca715d0cd0eed84e9a63" + dependencies: + safer-buffer ">= 2.1.2 < 3" + icss-replace-symbols@^1.1.0: version "1.1.0" resolved "https://registry.yarnpkg.com/icss-replace-symbols/-/icss-replace-symbols-1.1.0.tgz#06ea6f83679a7749e386cfe1fe812ae5db223ded" @@ -3673,10 +3711,6 @@ jsonify@~0.0.0: version "0.0.0" resolved "https://registry.yarnpkg.com/jsonify/-/jsonify-0.0.0.tgz#2c74b6ee41d93ca51b7b5aaee8f503631d252a73" -jsonp-promise@^0.1.2: - version "0.1.2" - resolved "https://registry.yarnpkg.com/jsonp-promise/-/jsonp-promise-0.1.2.tgz#5c4365d9cbee99c79893a9c750a95f6db468997d" - jsonpointer@^4.0.0: version "4.0.1" resolved "https://registry.yarnpkg.com/jsonpointer/-/jsonpointer-4.0.1.tgz#4fd92cb34e0e9db3c89c8622ecf51f9b978c6cb9" @@ -5380,6 +5414,10 @@ qs@6.5.1, qs@~6.5.1: version "6.5.1" resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.1.tgz#349cdf6eef89ec45c12d7d5eb3fc0c870343a6d8" +qs@6.5.2: + version "6.5.2" + resolved "https://registry.yarnpkg.com/qs/-/qs-6.5.2.tgz#cb3ae806e8740444584ef154ce8ee98d403f3e36" + qs@~6.3.0: version "6.3.2" resolved "https://registry.yarnpkg.com/qs/-/qs-6.3.2.tgz#e75bd5f6e268122a2a0e0bda630b2550c166502c" @@ -5436,6 +5474,15 @@ raw-body@2.3.2: iconv-lite "0.4.19" unpipe "1.0.0" +raw-body@2.3.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/raw-body/-/raw-body-2.3.3.tgz#1b324ece6b5706e153855bc1148c65bb7f6ea0c3" + dependencies: + bytes "3.0.0" + http-errors "1.6.3" + iconv-lite "0.4.23" + unpipe "1.0.0" + rc@^1.1.7: version "1.2.6" resolved "https://registry.yarnpkg.com/rc/-/rc-1.2.6.tgz#eb18989c6d4f4f162c399f79ddd29f3835568092" @@ -5848,6 +5895,10 @@ safe-regex@^1.1.0: dependencies: ret "~0.1.10" +"safer-buffer@>= 2.1.2 < 3": + version "2.1.2" + resolved "https://registry.yarnpkg.com/safer-buffer/-/safer-buffer-2.1.2.tgz#44fa161b0187b9549dd84bb91802f9bd8385cd6a" + sass-graph@^2.2.4: version "2.2.4" resolved "https://registry.yarnpkg.com/sass-graph/-/sass-graph-2.2.4.tgz#13fbd63cd1caf0908b9fd93476ad43a51d1e0b49" @@ -6195,6 +6246,10 @@ static-extend@^0.1.1: version "1.4.0" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.4.0.tgz#bb73d446da2796106efcc1b601a253d6c46bd087" +"statuses@>= 1.4.0 < 2": + version "1.5.0" + resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.5.0.tgz#161c7dac177659fd9811f43771fa99381478628c" + statuses@~1.3.1: version "1.3.1" resolved "https://registry.yarnpkg.com/statuses/-/statuses-1.3.1.tgz#faf51b9eb74aaef3b3acf4ad5f61abf24cb7b93e" @@ -6621,6 +6676,12 @@ unique-slug@^2.0.0: dependencies: imurmurhash "^0.1.4" +unique-string@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/unique-string/-/unique-string-1.0.0.tgz#9e1057cca851abb93398f8b33ae187b99caec11a" + dependencies: + crypto-random-string "^1.0.0" + units-css@^0.4.0: version "0.4.0" resolved "https://registry.yarnpkg.com/units-css/-/units-css-0.4.0.tgz#d6228653a51983d7c16ff28f8b9dc3b1ffed3a07"