├── .babelrc ├── .editorconfig ├── .eslintrc ├── .gitignore ├── .travis.yml ├── LICENSE ├── README.md ├── client ├── IconTemplate.png ├── IconTemplate@2x.png ├── app.js ├── components │ ├── calendar │ │ ├── calendar.js │ │ └── day.js │ ├── events │ │ ├── event.js │ │ └── eventList.js │ └── icon.js ├── index.html ├── router.js ├── style.css └── utils │ ├── eventUtils.js │ └── timeUtils.js ├── index.js ├── package.json ├── scripts ├── clearauth.js ├── cleardb.js └── clearsynctokens.js ├── server ├── CalendarStore.js ├── SyncService.js ├── api │ ├── CalendarAPI.js │ └── ProfileAPI.js ├── oauth │ └── ElectronGoogleAuth.js └── server.js └── tests └── client └── utils ├── eventUtils.js └── timeUtils.js /.babelrc: -------------------------------------------------------------------------------- 1 | { 2 | "presets": ["react"], 3 | "plugins": ["transform-class-properties", "transform-es2015-modules-commonjs", "transform-async-to-generator"] 4 | } 5 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | root = true 2 | 3 | [*] 4 | 5 | indent_style = space 6 | indent_size = 2 7 | 8 | end_of_line = lf 9 | charset = utf-8 10 | trim_trailing_whitespace = true 11 | insert_final_newline = true 12 | 13 | [*.md] 14 | trim_trailing_whitespace = false -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "airbnb", 3 | "parser": "babel-eslint", 4 | "rules": { 5 | "comma-dangle": ["error", "never"], 6 | "max-len": 0, 7 | "no-param-reassign": 0, 8 | "import/no-unresolved": 0, 9 | "no-underscore-dangle": 0, 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .DS_Store 3 | secrets.json 4 | npm-debug.log 5 | events.db 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "6" 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Brian Mathews 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy of 6 | this software and associated documentation files (the "Software"), to deal in 7 | the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 9 | the Software, and to permit persons to whom the Software is furnished to do so, 10 | subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 17 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR 18 | COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER 19 | IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN 20 | CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 21 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ## WIP - Electron-powered Google calendar menubar app 2 | _Not ready for daily usage_ 3 | 4 |

5 | 6 |

7 | 8 | ### Install 9 | 10 | With npm and node (v6+) installed, do: 11 | 12 | ``` 13 | $ npm install 14 | $ npm start 15 | ``` 16 | 17 | Before you can start the app, you need Google OAuth client credentials with the Calendar API enabled: 18 | 19 | 1. Create a project here: https://console.developers.google.com/iam-admin/projects?pli=1 20 | 2. Go to the API Manager and enable the `Calendar API` 21 | 3. Create Credentials > OAuth Client Id, then choose "Other" as the application type 22 | 23 | A `./secrets.json` file is required, with the following clientId and clientSecret: 24 | 25 | ``` 26 | { 27 | "oauth": { 28 | "clientId": "", 29 | "clientSecret": "" 30 | } 31 | } 32 | ``` 33 | 34 | ### npm scripts 35 | 36 | Open dev tools when app is started with: 37 | ``` 38 | $ npm run dev 39 | ``` 40 | 41 | Run tests with: 42 | ``` 43 | npm test 44 | ``` 45 | 46 | Lint with: 47 | ``` 48 | npm run lint 49 | ``` 50 | 51 | Clear out your local database with: 52 | ``` 53 | $ npm run clear-data 54 | ``` 55 | -------------------------------------------------------------------------------- /client/IconTemplate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmathews/menubar-calendar/6198eac73708dc2a12464b309cbc032532a09ae1/client/IconTemplate.png -------------------------------------------------------------------------------- /client/IconTemplate@2x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/bmathews/menubar-calendar/6198eac73708dc2a12464b309cbc032532a09ae1/client/IconTemplate@2x.png -------------------------------------------------------------------------------- /client/app.js: -------------------------------------------------------------------------------- 1 | import { 2 | ipcRenderer as ipc, 3 | shell 4 | } from 'electron'; 5 | 6 | import React from 'react'; 7 | import Icon from './components/icon'; 8 | import Calendar from './components/calendar/calendar'; 9 | import EventList from './components/events/eventList'; 10 | import eventUtils from './utils/eventUtils'; 11 | 12 | class App extends React.Component { 13 | 14 | state = { 15 | events: [], 16 | view: 'month' 17 | } 18 | 19 | componentDidMount() { 20 | ipc.on('events.synced', this._updateEvents); 21 | ipc.on('app.after-show', this._resetDate); 22 | } 23 | 24 | componentWillUnmount() { 25 | ipc.off('events.synced', this._updateEvents); 26 | ipc.off('app.after-show', this._resetDate); 27 | } 28 | 29 | /* 30 | * Fired when sync service has updated 31 | */ 32 | 33 | _updateEvents = (sender, events) => { 34 | this.setState({ events: events.sort((a, b) => { 35 | const aStart = eventUtils.getEventStartDate(a); 36 | const bStart = eventUtils.getEventStartDate(b); 37 | if (aStart < bStart) return -1; 38 | if (aStart > bStart) return 1; 39 | return 0; 40 | }) }); 41 | } 42 | 43 | _handleMenuClick = () => { 44 | this.setState({ view: this.state.view === 'month' ? 'week' : 'month' }); 45 | } 46 | 47 | /* 48 | * Fired when app is shown - set date to today 49 | */ 50 | 51 | _resetDate = () => { 52 | this.refs.calendar.changeDate(); 53 | } 54 | 55 | /* 56 | * When a date is selected, tell the eventList to scroll to it 57 | */ 58 | 59 | _handleCalendarSelect = (date) => { 60 | const el = this.refs.eventlist; 61 | el.scrollToDate(date); 62 | } 63 | 64 | /* 65 | * When a header is clicked, select the date 66 | */ 67 | 68 | _handleHeaderClick = (date) => { 69 | this.refs.calendar.changeDate(date, true); 70 | } 71 | 72 | /* 73 | * When an event is clicked, open in browser 74 | */ 75 | 76 | _handleEventClick = (event) => { 77 | this.refs.calendar.changeDate(eventUtils.getEventStartDate(event), true); 78 | shell.openExternal(event.htmlLink); 79 | } 80 | 81 | /* 82 | * Render 83 | */ 84 | 85 | render() { 86 | return ( 87 |
88 |
89 | 90 |
91 | 99 | 106 |
107 | ); 108 | } 109 | } 110 | 111 | export default App; 112 | -------------------------------------------------------------------------------- /client/components/calendar/calendar.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import CSSTransitionGroup from 'react-addons-css-transition-group'; 3 | import Day from './day'; 4 | import Icon from '../icon'; 5 | import timeUtils from '../../utils/timeUtils'; 6 | import eventUtils from '../../utils/eventUtils'; 7 | 8 | class Calendar extends React.Component { 9 | 10 | static propTypes = { 11 | onChange: React.PropTypes.func, 12 | selectedDate: React.PropTypes.object, 13 | viewDate: React.PropTypes.object, 14 | events: React.PropTypes.array, 15 | view: React.PropTypes.oneOf(['month', 'week']) 16 | } 17 | 18 | static defaultProps = { 19 | selectedDate: new Date() 20 | } 21 | 22 | state = { 23 | selectedDate: this.props.selectedDate, 24 | viewDate: this.props.selectedDate 25 | } 26 | 27 | 28 | /* 29 | * Navigate forward/backward by month or week 30 | */ 31 | 32 | navigate = (amount) => { 33 | const now = new Date(); 34 | 35 | if (this.props.view === 'month') { 36 | const next = timeUtils.addMonths(this.state.viewDate, amount); 37 | if (timeUtils.areSameMonth(now, next)) { 38 | this.changeDate(now); 39 | } else { 40 | this.changeDate(next); 41 | } 42 | } else { 43 | const next = timeUtils.addWeeks(this.state.viewDate, amount); 44 | if (timeUtils.areSameWeek(now, next)) { 45 | this.changeDate(now); 46 | } else { 47 | this.changeDate(next); 48 | } 49 | } 50 | } 51 | 52 | 53 | /* 54 | * Change the date, optionally emit onChange event 55 | */ 56 | 57 | changeDate = (d = new Date(), silent = false) => { 58 | const direction = d.getTime() < this.state.viewDate.getTime() ? 'left' : 'right'; 59 | 60 | this.setState({ 61 | selectedDate: d, 62 | viewDate: d, 63 | direction 64 | }); 65 | 66 | if (this.props.onChange && !silent) this.props.onChange(d); 67 | } 68 | 69 | 70 | /* 71 | * Render days between start/end 72 | */ 73 | 74 | _renderDays(start, end) { 75 | const count = timeUtils.daysBetween(end, start); 76 | let current = start; 77 | const elements = []; 78 | for (let i = 0; i < count; i++) { 79 | elements.push( 80 | 88 | ); 89 | current = timeUtils.addDays(current, 1); 90 | } 91 | return elements; 92 | } 93 | 94 | 95 | /* 96 | * Render the week view for the current viewDate 97 | */ 98 | 99 | _renderWeekView() { 100 | const startDate = timeUtils.getFirstDayOfWeek(this.state.viewDate); 101 | const endDate = timeUtils.addDays(startDate, 7); 102 | return this._renderDays(startDate, endDate); 103 | } 104 | 105 | 106 | /* 107 | * Render the month view for the current viewDate 108 | */ 109 | 110 | _renderMonthView() { 111 | const startDate = timeUtils.getFirstDayOfMonth(this.state.viewDate); 112 | startDate.setDate(startDate.getDate() - startDate.getDay()); // first day of week 113 | const endDate = timeUtils.addDays(startDate, 42); 114 | return this._renderDays(startDate, endDate); 115 | } 116 | 117 | 118 | /* 119 | * Handle when current month is clicked 120 | */ 121 | 122 | _handleCurrentMonthClick = () => { 123 | this.changeDate(); 124 | } 125 | 126 | 127 | /* 128 | * Handle when left arrow is clicked. 129 | */ 130 | 131 | _handleNavigateLeftClick = () => { 132 | this.navigate(-1); 133 | } 134 | 135 | 136 | /* 137 | * Handle when right arrow is clicked 138 | */ 139 | 140 | _handleNavigateRightClick = () => { 141 | this.navigate(1); 142 | } 143 | 144 | 145 | /* 146 | * Render days of the week labels 147 | */ 148 | 149 | _renderDaysOfWeek() { 150 | const weeks = []; 151 | for (let i = 0; i < 7; i++) { 152 | weeks.push( 153 | {timeUtils.getShortDayOfWeek(i)} 154 | ); 155 | } 156 | return weeks; 157 | } 158 | 159 | 160 | render() { 161 | const animation = this.state.direction === 'left' ? 'slide-left' : 'slide-right'; 162 | const key = this.props.view === 'month' ? this.state.viewDate.getMonth() : Math.round(timeUtils.getFirstDayOfWeek(this.state.viewDate).getDate() / 7); 163 | return ( 164 |
165 |
166 | 167 |
168 | {timeUtils.getFullMonth(this.state.viewDate)} 169 | {this.state.viewDate.getFullYear()} 170 |
171 |
172 |
173 | 174 |
175 |
176 | 177 |
178 |
179 |
{this._renderDaysOfWeek()}
180 | 181 |
182 |
183 | {this.props.view === 'month' ? this._renderMonthView() : this._renderWeekView()} 184 |
185 |
186 |
187 |
188 | ); 189 | } 190 | } 191 | 192 | export default Calendar; 193 | -------------------------------------------------------------------------------- /client/components/calendar/day.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import classNames from 'classnames'; 3 | import timeUtils from '../../utils/timeUtils'; 4 | import eventUtils from '../../utils/eventUtils'; 5 | 6 | class Day extends React.Component { 7 | static propTypes = { 8 | date: React.PropTypes.object, 9 | onClick: React.PropTypes.func, 10 | selected: React.PropTypes.bool, 11 | isDifferentMonth: React.PropTypes.bool, 12 | viewDate: React.PropTypes.object, 13 | events: React.PropTypes.array 14 | } 15 | 16 | 17 | /* 18 | * Total the number of hours of events, then display an indicator based on 8 hours a day 19 | */ 20 | 21 | _renderEventIndicator() { 22 | if (this.props.events.length) { 23 | const eventHours = this.props.events.reduce((total, e) => ( 24 | total + eventUtils.getHoursForEventOnDate(e, this.props.date) 25 | ), 0); 26 | const width = Math.min(eventHours / 8, 1); 27 | return ( 28 | 29 | ); 30 | } 31 | return null; 32 | } 33 | 34 | /* 35 | * Call props.onClick with current date 36 | */ 37 | _handleClick = () => { 38 | this.props.onClick(this.props.date); 39 | } 40 | 41 | render() { 42 | const now = new Date(); 43 | now.setHours(0, 0, 0, 0); 44 | 45 | const dayClass = classNames({ 46 | day: true, 47 | selected: this.props.selected, 48 | 'different-month': this.props.isDifferentMonth, 49 | past: this.props.date < now, 50 | today: timeUtils.areSameDay(this.props.date, now) 51 | }); 52 | 53 | return ( 54 |
55 |
56 | 57 | {this.props.date.getDate()} 58 | 59 | 60 | {this._renderEventIndicator()} 61 | 62 |
63 | ); 64 | } 65 | } 66 | 67 | export default Day; 68 | -------------------------------------------------------------------------------- /client/components/events/event.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import timeUtils from '../../utils/timeUtils'; 3 | import eventUtils from '../../utils/eventUtils'; 4 | import classNames from 'classnames'; 5 | import md5 from 'md5'; 6 | 7 | class Event extends React.Component { 8 | static propTypes = { 9 | event: React.PropTypes.object.isRequired, 10 | date: React.PropTypes.object.isRequired, 11 | onClick: React.PropTypes.func 12 | } 13 | 14 | _handleEventClick = () => { 15 | if (this.props.onClick) { 16 | this.props.onClick(this.props.event); 17 | } 18 | } 19 | 20 | _getTimeRange(start, end) { 21 | if (timeUtils.areSameDay(start, end)) { 22 | // same day, format normally 23 | return `${timeUtils.formatTime(start, 'ampm')} - ${timeUtils.formatTime(end, 'ampm')}`; 24 | } else if (timeUtils.areSameDay(start, this.props.date)) { 25 | return `Starts at ${timeUtils.formatTime(start, 'ampm')}`; 26 | } else if (timeUtils.areSameDay(end, this.props.date)) { 27 | return `Ends at ${timeUtils.formatTime(end, 'ampm')}`; 28 | } 29 | return 'All day'; 30 | } 31 | 32 | _renderAttendees(attendees) { 33 | console.log(attendees); 34 | if (attendees) { 35 | const filtered = attendees.filter(a => a.responseStatus === 'accepted'); 36 | const circles = filtered.slice(0, 3).map((a, i) => ( 37 |
42 |
43 | )); 44 | 45 | let roundy; 46 | if (filtered.length > 3) { 47 | roundy = ( 48 |
+{filtered.length - 3}
49 | ); 50 | } 51 | 52 | return ( 53 |
54 | {circles} 55 | {roundy} 56 |
57 | ); 58 | } 59 | return null; 60 | } 61 | 62 | render() { 63 | const event = this.props.event; 64 | const start = eventUtils.getEventStartDate(event); 65 | const end = eventUtils.getEventEndDate(event); 66 | let timeRange = 'All day'; 67 | if (!event.start.date) { 68 | timeRange = this._getTimeRange(start, end); 69 | } 70 | 71 | const now = new Date(); 72 | 73 | const eventClasses = classNames({ 74 | event: true, 75 | past: end < now, 76 | current: timeUtils.areSameDay(now, this.props.date) && now >= start && now <= end 77 | }); 78 | 79 | return ( 80 |
81 |
82 | {event.summary || '(No title)'} 83 |
84 | {event.location} 85 |
86 |
87 |
{timeRange}
88 | {this._renderAttendees(event.attendees)} 89 |
90 | ); 91 | } 92 | } 93 | 94 | export default Event; 95 | -------------------------------------------------------------------------------- /client/components/events/eventList.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import timeUtils from '../../utils/timeUtils'; 3 | import eventUtils from '../../utils/eventUtils'; 4 | import Event from './event'; 5 | import _ from 'lodash'; 6 | 7 | class EventList extends React.Component { 8 | 9 | static propTypes = { 10 | onEventClick: React.PropTypes.func, 11 | onHeaderClick: React.PropTypes.func, 12 | events: React.PropTypes.array.isRequired 13 | } 14 | 15 | state = { 16 | groupedEvents: {} 17 | } 18 | 19 | componentWillReceiveProps(nextProps) { 20 | this.state.groupedEvents = eventUtils.groupEventsByDate(nextProps.events); 21 | } 22 | 23 | componentWillUnmount() { 24 | if (this.state.animationFrame) { 25 | cancelAnimationFrame(this.state.animationFrame); 26 | } 27 | } 28 | 29 | 30 | /* 31 | * Get the group for a date 32 | */ 33 | 34 | _getGroupForDate(date) { 35 | return timeUtils.prettyFormatDate(date); 36 | } 37 | 38 | 39 | /* 40 | * Scroll to a date 41 | */ 42 | 43 | scrollToDate(date) { 44 | const group = this._getGroupForDate(date); 45 | 46 | const el = this.refs[group]; 47 | if (el) { 48 | if (this.state.animationFrame) { 49 | cancelAnimationFrame(this.state.animationFrame); 50 | } 51 | this._animateContainer(el.offsetParent, el); 52 | } 53 | } 54 | 55 | 56 | /* 57 | * Smoothly animate a container to an element 58 | */ 59 | 60 | _animateContainer(container, el) { 61 | function easeInOutQuad(t, b, c, d) { 62 | t /= d / 2; 63 | if (t < 1) return c / 2 * t * t + b; 64 | t--; 65 | return -c / 2 * (t * (t - 2) - 1) + b; 66 | } 67 | 68 | const target = el.offsetTop - 10; 69 | const start = container.scrollTop; 70 | const time = 250; 71 | let elapsed = 0; 72 | let last = new Date().getTime(); 73 | 74 | const anim = () => { 75 | const req = requestAnimationFrame(() => { 76 | const now = new Date().getTime(); 77 | const delta = now - last; 78 | elapsed += delta; 79 | const val = easeInOutQuad(Math.min(elapsed, time), start, target - start, time); 80 | container.scrollTop = val; 81 | last = now; 82 | if (val !== target) { 83 | anim(); 84 | } else { 85 | this.state.animationFrame = null; 86 | } 87 | }); 88 | this.state.animationFrame = req; 89 | }; 90 | anim(); 91 | } 92 | 93 | 94 | /* 95 | * Handle when an event is clicked on 96 | */ 97 | 98 | _handleEventClick = (event) => { 99 | if (this.props.onEventClick) { 100 | this.props.onEventClick(event); 101 | } 102 | } 103 | 104 | _getDateFromGroup(group) { 105 | return new Date(group.substr(group.indexOf(' ') + 1)); 106 | } 107 | 108 | 109 | /* 110 | * Handle when a group header is clicked on 111 | */ 112 | 113 | _handleHeaderClick = (e) => { 114 | const group = e.currentTarget.innerText; 115 | if (this.props.onHeaderClick) { 116 | const d = this._getDateFromGroup(group); 117 | this.props.onHeaderClick(d); 118 | } 119 | } 120 | 121 | 122 | /* 123 | * Render the individual event item 124 | */ 125 | 126 | _renderEvent = (event, idx, group) => ( 127 | 128 | ) 129 | 130 | 131 | /* 132 | * Render the list of events 133 | */ 134 | 135 | render() { 136 | const items = _.map(this.state.groupedEvents, (subItems, key) => { 137 | const header = ( 138 |
{key}
139 | ); 140 | const els = subItems.map((e, i) => ( 141 | this._renderEvent(e, i, key) 142 | )); 143 | 144 | if (!els.length && key.indexOf('Today') === 0) { 145 | els.push( 146 |
No events today!
147 | ); 148 | } 149 | 150 | return [header].concat(els); 151 | }); 152 | 153 | return ( 154 |
{items}
155 | ); 156 | } 157 | } 158 | 159 | export default EventList; 160 | -------------------------------------------------------------------------------- /client/components/icon.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | /* Subset of the SVG icon collection from the Polymer project (goo.gl/N7SB5G) */ 4 | 5 | class Icon extends React.Component { 6 | static propTypes = { 7 | icon: React.PropTypes.string.isRequired, 8 | size: React.PropTypes.oneOfType([ 9 | React.PropTypes.string, 10 | React.PropTypes.number 11 | ]), 12 | style: React.PropTypes.object 13 | } 14 | 15 | static defaultProps = { 16 | size: 24 17 | } 18 | 19 | renderGraphic() { 20 | switch (this.props.icon) { 21 | case 'menu': 22 | return ( 23 | 24 | 25 | 26 | 27 | ); 28 | case 'chevron-right': 29 | return ( 30 | 31 | ); 32 | case 'chevron-left': 33 | return ( 34 | 35 | ); 36 | default: 37 | return null; 38 | } 39 | } 40 | 41 | render() { 42 | let styles = { 43 | fill: 'currentcolor', 44 | verticalAlign: 'middle', 45 | width: this.props.size, 46 | height: this.props.size 47 | }; 48 | return ( 49 | 50 | {this.renderGraphic()} 51 | 52 | ); 53 | } 54 | } 55 | 56 | export default Icon; 57 | -------------------------------------------------------------------------------- /client/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | Menu Calendar 4 | 5 | 6 | 7 |
8 | 9 | 15 | 16 | -------------------------------------------------------------------------------- /client/router.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { render } from 'react-dom'; 3 | import { Router, Route, hashHistory } from 'react-router'; 4 | import App from './app.js'; 5 | 6 | render(( 7 | 8 | 9 | 10 | ), document.getElementById('react-root')); 11 | -------------------------------------------------------------------------------- /client/style.css: -------------------------------------------------------------------------------- 1 | *, *:after, *:before { 2 | box-sizing: border-box; 3 | } 4 | 5 | html { 6 | overflow: hidden; 7 | overflow-y: overlay; 8 | } 9 | 10 | body, html { 11 | padding: 0; 12 | margin: 0; 13 | height: 100%; 14 | width: 100%; 15 | -webkit-user-select: none; 16 | cursor: default; 17 | } 18 | 19 | body { 20 | padding-top: 10px; 21 | } 22 | 23 | #react-root { 24 | width: 100%; 25 | height: 100%; 26 | border-radius: 4px; 27 | background-color: #fff; 28 | font-size: 13px; 29 | font-family: ".SFNSDisplay-Regular", "Helvetica Neue", sans-serif; 30 | color: #444; 31 | line-height: 1.4; 32 | -webkit-font-smoothing: antialiased; 33 | } 34 | 35 | #react-root:before { 36 | content: ""; 37 | position: absolute; 38 | left: 50%; 39 | margin-left: -8px; 40 | margin-top: -8px; 41 | display: block; 42 | width: 0; 43 | height: 0; 44 | border-left: 8px solid transparent; 45 | border-right: 8px solid transparent; 46 | border-bottom: 8px solid #547DF9; 47 | } 48 | 49 | #react-root > .flex-column { 50 | height: 100%; 51 | } 52 | 53 | p, h1, h2, h3, h4, h5, h6 { 54 | margin: 0 0 8px 0; 55 | line-height: 1.4; 56 | } 57 | 58 | p:last-child { 59 | margin-bottom: 0; 60 | } 61 | 62 | button, .button { 63 | display: inline-block; 64 | background: #5CC090; 65 | color: #fff; 66 | border-radius: 3px; 67 | border: 2px solid #5CC090; 68 | font-weight: 500; 69 | font-family: ".SFNSDisplay-Semibold", "Helvetica Neue", sans-serif; 70 | font-size: 13px; 71 | line-height: 1.4; 72 | padding: 6px 12px; 73 | transition: all 200ms; 74 | } 75 | 76 | button:hover, .button:hover { 77 | box-shadow: 0px 1px 2px rgba(0,0,0,.2); 78 | } 79 | 80 | button:disabled, .button:disabled { 81 | opacity: .5; 82 | pointer-events: none; 83 | } 84 | 85 | .menu-icon { 86 | position: absolute; 87 | top: 28px; 88 | left: 15px; 89 | color: #fff; 90 | z-index: 3; 91 | display: none; 92 | } 93 | 94 | .content { 95 | padding: 16px; 96 | } 97 | 98 | button, .button { 99 | outline: none; 100 | } 101 | 102 | .label { 103 | float: left; 104 | display: inline-block; 105 | color: #aaa; 106 | line-height: 40px; 107 | padding-left: 14px; 108 | } 109 | 110 | .margin-0 { 111 | margin: 0; 112 | } 113 | 114 | .flex-row { 115 | display: flex; 116 | flex-direction: row; 117 | } 118 | 119 | .flex-column { 120 | display: flex; 121 | flex-direction: column; 122 | } 123 | 124 | .flex-1 { 125 | flex: 1; 126 | position: relative; 127 | } 128 | 129 | .calendar { 130 | position: relative; 131 | z-index: 2; 132 | overflow: hidden; 133 | box-shadow: 0px 2px 4px rgba(0,0,0,.08); 134 | } 135 | 136 | .calendar--month { 137 | height: 330px; 138 | } 139 | 140 | .calendar--week { 141 | height: 130px; 142 | } 143 | 144 | .calendar .calendar-slider { 145 | width: 100%; 146 | } 147 | 148 | .calendar .days-of-week { 149 | display: flex; 150 | background: #547DF9; 151 | margin-bottom: 6px; 152 | } 153 | 154 | .calendar .days-of-week > span { 155 | flex: 0 0 calc(100% / 7); 156 | text-align: center; 157 | color: rgba(255,255,255,.4); 158 | line-height: 36px; 159 | text-transform: uppercase; 160 | font-size: 11px; 161 | font-weight: 600; 162 | font-family: ".SFNSDisplay-Bold", "Helvetica Neue", sans-serif; 163 | } 164 | 165 | .calendar .days { 166 | display: flex; 167 | flex-wrap: wrap; 168 | } 169 | 170 | .calendar .day { 171 | text-align: center; 172 | width: calc(100% / 7); 173 | border-radius: 2px; 174 | position: relative; 175 | height: 40px; 176 | display: flex; 177 | align-items: center; 178 | justify-content: center; 179 | transition: color 125ms ease-in-out; 180 | flex-direction: column; 181 | } 182 | 183 | .calendar .day > span { 184 | position: relative; 185 | } 186 | 187 | .calendar .day:before { 188 | display: block; 189 | content: ""; 190 | background-color: transparent; 191 | position: absolute; 192 | position: absolute; 193 | width: 35px; 194 | height: 35px; 195 | top: 50%; 196 | right: 0; 197 | bottom: 0; 198 | left: 50%; 199 | border-radius: 50%; 200 | background-color: #547DF9; 201 | opacity: 0; 202 | transform: translate(-50%, -50%) scale(.75); 203 | transition: opacity 125ms ease-in-out, transform 125ms ease-in-out;; 204 | } 205 | 206 | @-webkit-keyframes bounceIn { 207 | 0%,100%,20%,40%,50%,70% { 208 | animation-timing-function: ease-in-out; 209 | } 210 | 211 | 0% { 212 | transform: translate(-50%, -50%) scale3d(.75,.75,.75) 213 | } 214 | 215 | 50% { 216 | transform: translate(-50%, -50%) scale3d(1.05,1.05,1.05) 217 | } 218 | 219 | 70% { 220 | transform: translate(-50%, -50%) scale3d(.98,.98,.98) 221 | } 222 | 223 | 100% { 224 | transform: translate(-50%, -50%) scale3d(1,1,1) 225 | } 226 | } 227 | 228 | .calendar .day:hover { 229 | } 230 | 231 | .calendar .day.selected:before { 232 | opacity: 1; 233 | border-radius: 50%; 234 | background-color: #547DF9; 235 | transform: translate(-50%, -50%) scale(1); 236 | animation: bounceIn 250ms ease-in-out; 237 | } 238 | 239 | .calendar .day.selected { 240 | color: #fff; 241 | } 242 | 243 | .calendar .day.different-month { 244 | color: #bbb; 245 | } 246 | 247 | .calendar .day.today:not(.selected) { 248 | color: #547DF9; 249 | } 250 | 251 | .calendar .header { 252 | background: #547DF9; 253 | color: #fff; 254 | padding: 10px 10px 0px 15px; 255 | display: flex; 256 | border-radius: 3px 3px 0 0; 257 | } 258 | 259 | .calendar .header .date { 260 | flex: 1; 261 | /*margin-left: 35px;*/ 262 | } 263 | 264 | .calendar .header .month { 265 | font-size: 18px; 266 | font-weight: 400; 267 | font-family: ".SFNSDisplay-Semibold", "Helvetica Neue", sans-serif; 268 | line-height: 40px; 269 | } 270 | 271 | .calendar .header .year { 272 | font-size: 18px; 273 | font-family: ".SFNSDisplay-Light", "Helvetica Neue", sans-serif; 274 | font-weight: 100; 275 | color: rgba(255,255,255,.5); 276 | padding-left: 4px; 277 | } 278 | 279 | .calendar .header .next, 280 | .calendar .header .previous { 281 | padding: 8px; 282 | transition: background-color 125ms ease-in-out; 283 | } 284 | 285 | .calendar .header .previous:hover, 286 | .calendar .header .next:hover { 287 | border-radius: 2px; 288 | background-color: rgba(0,0,0,.04); 289 | } 290 | 291 | .calendar .dots { 292 | text-align: center; 293 | width: 65%; 294 | height: 4px; 295 | } 296 | 297 | 298 | .calendar .dots .dot { 299 | width: 4px; 300 | height: 4px; 301 | border-radius: 4px; 302 | background-color: rgba(0,0,0,.15); 303 | display: inline-block; 304 | margin-right: 2px; 305 | position: relative; 306 | min-width: 4px; 307 | } 308 | 309 | .calendar .dots .dot:last-child { 310 | margin-right: 0; 311 | } 312 | 313 | .event-list { 314 | position: relative; 315 | letter-spacing: .4; 316 | padding-bottom: 10px; 317 | background-color: #F1F5F6; 318 | color: #444; 319 | flex: 1; 320 | overflow-y: overlay; 321 | overflow-x: hidden; 322 | border-radius: 0 0 3px 3px; 323 | } 324 | 325 | .event-list::-webkit-scrollbar { 326 | width: 4px; 327 | background: transparent; 328 | -webkit-appearance: none; 329 | } 330 | 331 | .event-list::-webkit-scrollbar * { 332 | background: transparent; 333 | } 334 | 335 | .event-list::-webkit-scrollbar-track { 336 | border-radius:5px; 337 | background: transparent; 338 | } 339 | 340 | .event-list::-webkit-scrollbar-thumb { 341 | border: 4px solid transparent; 342 | background-clip: padding-box; 343 | border-radius: 5px; 344 | background-color: rgba(0,0,0,0); 345 | min-height: 30px; 346 | } 347 | 348 | .event .attendees { 349 | flex: 1 0 100%; 350 | padding-left: 12px; 351 | padding-bottom: 10px; 352 | } 353 | 354 | .event .attendees .avatar { 355 | background-size: cover; 356 | background-position: center center; 357 | width: 26px; 358 | height: 26px; 359 | border-radius: 50%; 360 | vertical-align: middle; 361 | display: inline-block; 362 | } 363 | 364 | .event .attendees .avatar+.avatar { 365 | margin-left: 4px; 366 | } 367 | 368 | .event .attendees .more { 369 | vertical-align: middle; 370 | margin-left: 4px; 371 | border-radius: 50%; 372 | width: 26px; 373 | height: 26px; 374 | border: 1px solid #979797; 375 | color: #999; 376 | font-size: 11px; 377 | display: inline-flex; 378 | justify-content: center; 379 | align-items: center; 380 | } 381 | 382 | .event-list:hover::-webkit-scrollbar-thumb { 383 | background-color: rgba(0,0,0,0.3); 384 | } 385 | 386 | .event-list::-webkit-scrollbar-thumb:hover { 387 | background-color: rgba(0,0,0,0.4); 388 | } 389 | 390 | .event-list-header { 391 | margin-top: 12px; 392 | padding: 4px 12px 0px 12px; 393 | color: #888888; 394 | font-family: ".SFNSDisplay-Medium", "Helvetica Neue", sans-serif; 395 | font-size: 12px; 396 | } 397 | 398 | .event { 399 | display: flex; 400 | justify-content: space-between; 401 | flex-wrap: wrap; 402 | background: #fff; 403 | border-radius: 2px; 404 | box-shadow: 0px 1px 2px rgba(0,0,0,.1); 405 | margin: 8px 8px 4px 8px; 406 | font-family: ".SFNSDisplay-Medium", "Helvetica Neue", sans-serif; 407 | } 408 | 409 | .event.empty { 410 | text-align: center; 411 | display: block; 412 | background: none; 413 | box-shadow: none; 414 | padding: 6px; 415 | } 416 | 417 | .event.current, .event.current .time { 418 | color: #547DF9; 419 | } 420 | 421 | .event:not(.empty):hover { 422 | cursor: pointer; 423 | } 424 | 425 | .event .name, .event .time { 426 | padding: 10px 14px; 427 | } 428 | 429 | .event .name { 430 | flex: 1; 431 | align-items: center; 432 | padding-right: 0; 433 | overflow: hidden; 434 | white-space: nowrap; 435 | text-overflow: ellipsis; 436 | } 437 | 438 | .event .location { 439 | color: #888; 440 | font-size: 11px; 441 | margin-top: 5px; 442 | } 443 | 444 | .event .location:empty { 445 | display: none; 446 | } 447 | 448 | .event.past .name { 449 | color: #444444; 450 | } 451 | .event.past .time { 452 | color: #888; 453 | } 454 | 455 | .event .time { 456 | font-size: 11px; 457 | text-align: right; 458 | color: #888; 459 | padding-top: 11.5px; 460 | } 461 | 462 | /* slide right animation */ 463 | .slide-right-enter, .slide-right-leave { 464 | position: absolute; 465 | transition-timing-function: ease-in-out; 466 | transition-duration: 200ms; 467 | transition-property: transform, opacity; 468 | } 469 | 470 | .slide-right-enter { 471 | opacity: 0; 472 | transform: translate3d(100%, 0, 0); 473 | } 474 | 475 | .slide-right-enter.slide-right-enter-active { 476 | opacity: 1; 477 | transform: translate3d(0, 0, 0); 478 | } 479 | 480 | .slide-right-leave { 481 | opacity: 1; 482 | transform: translate3d(0, 0, 0); 483 | } 484 | 485 | .slide-right-leave.slide-right-leave-active { 486 | opacity: 0; 487 | transform: translate3d(-100%, 0, 0); 488 | } 489 | 490 | .slide-left-enter, .slide-left-leave { 491 | position: absolute; 492 | transition-timing-function: ease-in-out; 493 | transition-duration: 200ms; 494 | transition-property: transform, opacity; 495 | } 496 | 497 | /* slide left animation */ 498 | .slide-left-enter { 499 | opacity: 0; 500 | transform: translate3d(-100%, 0, 0); 501 | } 502 | 503 | .slide-left-enter.slide-left-enter-active { 504 | opacity: 1; 505 | transform: translate3d(0, 0, 0); 506 | } 507 | 508 | .slide-left-leave { 509 | opacity: 1; 510 | transform: translate3d(0, 0, 0); 511 | } 512 | 513 | .slide-left-leave.slide-left-leave-active { 514 | opacity: 0; 515 | transform: translate3d(100%, 0, 0); 516 | } 517 | 518 | /* slide up animation */ 519 | .slide-up-enter, .slide-up-leave { 520 | position: absolute; 521 | transition-timing-function: ease-in-out; 522 | transition-duration: 200ms; 523 | transition-property: transform, opacity; 524 | } 525 | 526 | .slide-up-enter { 527 | opacity: 0; 528 | transform: translate3d(0, 100%, 0); 529 | } 530 | 531 | .slide-up-enter.slide-up-enter-active { 532 | opacity: 1; 533 | transform: translate3d(0, 0, 0); 534 | } 535 | 536 | .slide-up-leave { 537 | opacity: 1; 538 | transform: translate3d(0, 0, 0); 539 | } 540 | 541 | .slide-up-leave.slide-up-leave-active { 542 | opacity: 0; 543 | transform: translate3d(0, -100%, 0); 544 | } 545 | 546 | [data-tooltip] { 547 | position: relative; 548 | } 549 | 550 | [data-tooltip]::before { 551 | content: attr(data-tooltip); 552 | display: block; 553 | position: absolute; 554 | opacity: 0; 555 | pointer-events: none; 556 | transition: all 250ms ease; 557 | transform: translate(-50%, 10px); 558 | left: 50%; 559 | bottom: 100%; 560 | background-color: rgba(0,0,0,.8); 561 | color: #fff; 562 | padding: 4px 8px; 563 | border-radius: 2px; 564 | font-size: 11px; 565 | } 566 | 567 | [data-tooltip]:hover::before { 568 | transform: translate(-50%, -10px); 569 | opacity: 1; 570 | pointer-events: auto; 571 | } 572 | -------------------------------------------------------------------------------- /client/utils/eventUtils.js: -------------------------------------------------------------------------------- 1 | import timeUtils from './timeUtils'; 2 | 3 | export default { 4 | 5 | getEventsForDay(date, events) { 6 | return events.filter((e) => { 7 | const start = this.getEventStartDate(e); 8 | const end = this.getEventEndDate(e); 9 | end.setHours(0, 0, 0, 0); // strip hours 10 | start.setHours(0, 0, 0, 0); 11 | return timeUtils.isBetween(date, start, end); 12 | }); 13 | }, 14 | 15 | getDatesForEvent(e) { 16 | const start = this.getEventStartDate(e); 17 | start.setHours(0, 0, 0, 0); 18 | const end = this.getEventEndDate(e); 19 | end.setHours(0, 0, 0, 0); 20 | const count = timeUtils.daysBetween(end, start); 21 | 22 | const dates = []; 23 | for (let i = 0; i <= count; i++) { 24 | const d = new Date(start); 25 | d.setDate(d.getDate() + i); 26 | dates.push(d); 27 | } 28 | 29 | return dates; 30 | }, 31 | 32 | getEventStartDate(e) { 33 | if (e.start.dateTime) { 34 | return new Date(e.start.dateTime); 35 | } 36 | const split = e.start.date.split('-'); 37 | return new Date(split[0], split[1] - 1, split[2]); 38 | }, 39 | 40 | getEventEndDate(e) { 41 | if (e.end.dateTime) { 42 | return new Date(e.end.dateTime); 43 | } 44 | const split = e.end.date.split('-'); 45 | // minus 1 on all-day events end.date is exclusive 46 | return new Date(split[0], split[1] - 1, split[2] - 1); 47 | }, 48 | 49 | getHoursForEventOnDate(e, date) { 50 | const start = this.getEventStartDate(e); 51 | const end = this.getEventEndDate(e); 52 | if (e.start.date) { 53 | if (timeUtils.isBetween(date, start, end)) { 54 | return 24; // all day during this date 55 | } 56 | return 0; 57 | } 58 | 59 | if (timeUtils.areSameDay(start, date)) { 60 | if (!timeUtils.areSameDay(start, end)) { 61 | return 24 - start.getHours(); 62 | } 63 | return end.getHours() - start.getHours(); 64 | } else if (timeUtils.areSameDay(end, date)) { 65 | return end.getHours(); 66 | } else if (timeUtils.isBetween(date, start, end)) { 67 | return 24; 68 | } 69 | 70 | return 0; 71 | }, 72 | 73 | groupEventsByDate(events, today = new Date()) { 74 | today.setHours(0, 0, 0, 0); 75 | const todayFormat = today.toISOString().substr(0, 10); 76 | const groups = { 77 | [todayFormat]: [] 78 | }; 79 | 80 | events.forEach(e => { 81 | const dates = this.getDatesForEvent(e); 82 | 83 | dates.forEach(d => { 84 | const format = d.toISOString().substr(0, 10); 85 | if (!groups[format]) { 86 | groups[format] = [e]; 87 | } else { 88 | groups[format].push(e); 89 | } 90 | }); 91 | }); 92 | 93 | const sortedGroups = {}; 94 | let keys = Object.keys(groups); 95 | keys = keys.sort(); 96 | for (const k of keys) { 97 | const split = k.split('-'); 98 | sortedGroups[timeUtils.prettyFormatDate(new Date(split[0], split[1] - 1, split[2]), today)] = groups[k]; 99 | } 100 | return sortedGroups; 101 | } 102 | 103 | }; 104 | -------------------------------------------------------------------------------- /client/utils/timeUtils.js: -------------------------------------------------------------------------------- 1 | export default { 2 | 3 | getDaysInMonth(d) { 4 | const resultDate = this.getFirstDayOfMonth(d); 5 | resultDate.setMonth(resultDate.getMonth() + 1); 6 | resultDate.setDate(resultDate.getDate() - 1); 7 | return resultDate.getDate(); 8 | }, 9 | 10 | getFirstDayOfMonth(d) { 11 | return new Date(d.getFullYear(), d.getMonth(), 1); 12 | }, 13 | 14 | getFirstWeekDay(d) { 15 | return this.getFirstDayOfMonth(d).getDay(); 16 | }, 17 | 18 | getFirstDayOfWeek(d) { 19 | const newDate = this.clone(d); 20 | newDate.setDate(newDate.getDate() - newDate.getDay()); 21 | return newDate; 22 | }, 23 | 24 | getTimeMode(d) { 25 | return d.getHours() >= 12 ? 'pm' : 'am'; 26 | }, 27 | 28 | getFullMonth(d) { 29 | const month = d.getMonth(); 30 | switch (month) { 31 | default: return 'Unknown'; 32 | case 0: return 'January'; 33 | case 1: return 'February'; 34 | case 2: return 'March'; 35 | case 3: return 'April'; 36 | case 4: return 'May'; 37 | case 5: return 'June'; 38 | case 6: return 'July'; 39 | case 7: return 'August'; 40 | case 8: return 'September'; 41 | case 9: return 'October'; 42 | case 10: return 'November'; 43 | case 11: return 'December'; 44 | } 45 | }, 46 | 47 | getShortMonth(d) { 48 | const month = d.getMonth(); 49 | switch (month) { 50 | default: return 'Unknown'; 51 | case 0: return 'Jan'; 52 | case 1: return 'Feb'; 53 | case 2: return 'Mar'; 54 | case 3: return 'Apr'; 55 | case 4: return 'May'; 56 | case 5: return 'Jun'; 57 | case 6: return 'Jul'; 58 | case 7: return 'Aug'; 59 | case 8: return 'Sep'; 60 | case 9: return 'Oct'; 61 | case 10: return 'Nov'; 62 | case 11: return 'Dec'; 63 | } 64 | }, 65 | 66 | getFullDayOfWeek(day) { 67 | switch (day) { 68 | default: return 'Unknown'; 69 | case 0: return 'Sunday'; 70 | case 1: return 'Monday'; 71 | case 2: return 'Tuesday'; 72 | case 3: return 'Wednesday'; 73 | case 4: return 'Thursday'; 74 | case 5: return 'Friday'; 75 | case 6: return 'Saturday'; 76 | } 77 | }, 78 | 79 | getShortDayOfWeek(day) { 80 | switch (day) { 81 | default: return 'Unknown'; 82 | case 0: return 'Sun'; 83 | case 1: return 'Mon'; 84 | case 2: return 'Tue'; 85 | case 3: return 'Wed'; 86 | case 4: return 'Thu'; 87 | case 5: return 'Fri'; 88 | case 6: return 'Sat'; 89 | } 90 | }, 91 | 92 | clone(d) { 93 | return new Date(d.getTime()); 94 | }, 95 | 96 | cloneAsDate(d) { 97 | const clonedDate = this.clone(d); 98 | clonedDate.setHours(0, 0, 0, 0); 99 | return clonedDate; 100 | }, 101 | 102 | isDateObject(d) { 103 | return d instanceof Date; 104 | }, 105 | 106 | addDays(d, days) { 107 | const newDate = this.clone(d); 108 | newDate.setDate(d.getDate() + days); 109 | return newDate; 110 | }, 111 | 112 | addWeeks(d, weeks) { 113 | const newDate = this.clone(d); 114 | newDate.setDate(newDate.getDate() + weeks * 7); 115 | return newDate; 116 | }, 117 | 118 | addMonths(d, months) { 119 | const newDate = this.clone(d); 120 | newDate.setDate(1); // first 121 | newDate.setMonth(d.getMonth() + months); 122 | return newDate; 123 | }, 124 | 125 | addYears(d, years) { 126 | const newDate = this.clone(d); 127 | newDate.setFullYear(d.getFullYear() + years); 128 | return newDate; 129 | }, 130 | 131 | setDay(d, day) { 132 | const newDate = this.clone(d); 133 | newDate.setDate(day); 134 | return newDate; 135 | }, 136 | 137 | setMonth(d, month) { 138 | const newDate = this.clone(d); 139 | newDate.setMonth(month); 140 | return newDate; 141 | }, 142 | 143 | setYear(d, year) { 144 | const newDate = this.clone(d); 145 | newDate.setFullYear(year); 146 | return newDate; 147 | }, 148 | 149 | setHours(d, hours) { 150 | const newDate = this.clone(d); 151 | newDate.setHours(hours); 152 | return newDate; 153 | }, 154 | 155 | setMinutes(d, minutes) { 156 | const newDate = this.clone(d); 157 | newDate.setMinutes(minutes); 158 | return newDate; 159 | }, 160 | 161 | isBetween(date, a, b) { 162 | return date >= a && date <= b; 163 | }, 164 | 165 | daysBetween(a, b) { 166 | return Math.floor((a - b) / 86400000); 167 | }, 168 | 169 | areSameDay(a, b) { 170 | return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate(); 171 | }, 172 | 173 | areSameMonth(a, b) { 174 | return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth(); 175 | }, 176 | 177 | areSameWeek(a, b) { 178 | // check within 7 days 179 | if (this.daysBetween(a, b) < 7) { 180 | a = this.clone(a); 181 | a.setDate(a.getDate() - a.getDay()); // first day of week 182 | b = this.clone(b); 183 | b.setDate(b.getDate() - b.getDay()); // first day of week 184 | return a.getDate() === b.getDate(); 185 | } 186 | return false; 187 | }, 188 | 189 | toggleTimeMode(d) { 190 | const newDate = this.clone(d); 191 | const hours = newDate.getHours(); 192 | 193 | newDate.setHours(hours - (hours > 12 ? -12 : 12)); 194 | return newDate; 195 | }, 196 | 197 | formatTime(date, format) { 198 | let hours = date.getHours(); 199 | let mins = date.getMinutes().toString(); 200 | 201 | if (format === 'ampm') { 202 | const isAM = hours < 12; 203 | const additional = isAM ? ' AM' : ' PM'; 204 | 205 | hours = hours % 12; 206 | hours = (hours || 12).toString(); 207 | if (mins.length < 2) mins = `0${mins}`; 208 | 209 | return `${hours}:${mins}${additional}`; 210 | } 211 | 212 | hours = hours.toString(); 213 | if (hours.length < 2) hours = `0${hours}`; 214 | if (mins.length < 2) mins = `0${mins}`; 215 | return `${hours}:${mins}`; 216 | }, 217 | 218 | prettyFormatDate(date, today = new Date()) { 219 | let prefix = this.getFullDayOfWeek(date.getDay()); 220 | if (date.getFullYear() === today.getFullYear() && date.getMonth() === today.getMonth()) { 221 | if (date.getDate() === today.getDate()) { 222 | prefix = 'Today'; 223 | } else if (date.getDate() - 1 === today.getDate()) { 224 | prefix = 'Tomorrow'; 225 | } 226 | } 227 | 228 | return `${prefix} ${date.getMonth() + 1}/${date.getDate()}/${String(date.getFullYear()).substr(2)}`; 229 | } 230 | }; 231 | -------------------------------------------------------------------------------- /index.js: -------------------------------------------------------------------------------- 1 | require('babel-register')({ 2 | sourceMaps: true 3 | }); 4 | require('./server/server.js'); 5 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "menu-calendar", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "index.js", 6 | "dependencies": { 7 | "babel-cli": "^6.9.0", 8 | "babel-plugin-transform-async-to-generator": "^6.8.0", 9 | "babel-plugin-transform-class-properties": "^6.9.1", 10 | "babel-plugin-transform-es2015-modules-commonjs": "^6.8.0", 11 | "babel-preset-react": "^6.5.0", 12 | "babel-register": "^6.9.0", 13 | "classnames": "^2.2.5", 14 | "configstore": "^1.2.1", 15 | "googleapis": "^4.0.0", 16 | "linvodb3": "^3.11.1", 17 | "lodash": "^3.10.1", 18 | "md5": "^2.1.0", 19 | "medeadown": "^1.1.8", 20 | "menubar": "^4.1.1", 21 | "moment": "^2.10.6", 22 | "node-fetch": "^1.3.2", 23 | "q": "^1.4.1", 24 | "querystring": "^0.2.0", 25 | "react": "^15.0.1", 26 | "react-addons-css-transition-group": "^15.0.1", 27 | "react-dom": "^15.0.1", 28 | "react-router": "^2.4.1", 29 | "redux": "^3.0.2" 30 | }, 31 | "devDependencies": { 32 | "babel-eslint": "^6.0.4", 33 | "babel-register": "^6.9.0", 34 | "electron-prebuilt": "^1.3.0", 35 | "eslint": "^2.11.1", 36 | "eslint-config-airbnb": "^9.0.1", 37 | "eslint-plugin-import": "^1.8.1", 38 | "eslint-plugin-jsx-a11y": "^1.2.3", 39 | "eslint-plugin-promise": "^1.3.2", 40 | "eslint-plugin-react": "^5.1.1", 41 | "eslint-plugin-standard": "^1.3.2", 42 | "faucet": "0.0.1", 43 | "rimraf": "^2.4.4", 44 | "tape": "^4.5.1" 45 | }, 46 | "scripts": { 47 | "dev": "NODE_ENV=development npm start", 48 | "start": "./node_modules/.bin/electron .", 49 | "clear-data": "node ./scripts/cleardb.js && node ./scripts/clearauth.js && node ./scripts/clearsynctokens.js", 50 | "tape": "tape -r babel-register tests/**/*.js | faucet", 51 | "test": "npm run lint && npm run tape", 52 | "lint": "eslint client server tests" 53 | }, 54 | "author": "", 55 | "license": "ISC" 56 | } 57 | -------------------------------------------------------------------------------- /scripts/clearauth.js: -------------------------------------------------------------------------------- 1 | const Configstore = require('configstore'); 2 | const conf = new Configstore('menu-calendar'); 3 | conf.set('auth', null); 4 | -------------------------------------------------------------------------------- /scripts/cleardb.js: -------------------------------------------------------------------------------- 1 | const rimraf = require('rimraf'); 2 | rimraf.sync('events.db', {}); 3 | -------------------------------------------------------------------------------- /scripts/clearsynctokens.js: -------------------------------------------------------------------------------- 1 | const Configstore = require('configstore'); 2 | const conf = new Configstore('menu-calendar'); 3 | conf.set('syncTokens', {}); 4 | -------------------------------------------------------------------------------- /server/CalendarStore.js: -------------------------------------------------------------------------------- 1 | import Q from 'q'; 2 | import LinvoDB from 'linvodb3'; 3 | import medeadown from 'medeadown'; 4 | LinvoDB.defaults.store = { db: medeadown }; 5 | LinvoDB.dbPath = process.cwd(); 6 | 7 | const Events = new LinvoDB('events', {}); 8 | 9 | class CalendarStore { 10 | 11 | /* 12 | * Save or update items 13 | */ 14 | 15 | setItems(items) { 16 | const list = items.map((i) => { 17 | const copy = Object.assign({}, i); 18 | const id = copy.id; 19 | copy._id = id; 20 | return copy; 21 | }); 22 | 23 | console.log('CalendarStore: #setItems: Saving all items: ', list.length); 24 | return Q.ninvoke(Events, 'save', list); 25 | } 26 | 27 | 28 | /* 29 | * Remove items 30 | */ 31 | 32 | removeItems(items) { 33 | const ids = items.map(i => i.id); 34 | console.log('CalendarStore: #removeItems: Removing items: ', ids.length); 35 | return Q.ninvoke(Events, 'remove', { _id: { $in: ids } }, { multi: true }); 36 | } 37 | 38 | 39 | /* 40 | * Get all items between start/end time range 41 | */ 42 | 43 | getByDate(start, end) { 44 | return new Promise((resolve, reject) => { 45 | Events.find({ 46 | $or: [{ 47 | 'start.dateTime': { 48 | $gte: start, 49 | $lte: end 50 | } 51 | }, { 52 | 'start.date': { 53 | $gte: start, 54 | $lte: end 55 | } 56 | }] 57 | }).exec((err, res) => { 58 | if (err) return reject(err); 59 | return resolve(res); 60 | }); 61 | }); 62 | } 63 | 64 | 65 | /* 66 | * Get all items 67 | */ 68 | getAll() { 69 | return new Promise((resolve, reject) => { 70 | Events.find({}).sort({ 'start.dateTime': 1 }).exec((err, res) => { 71 | if (err) return reject(err); 72 | return resolve(res); 73 | }); 74 | }); 75 | } 76 | } 77 | 78 | export default CalendarStore; 79 | -------------------------------------------------------------------------------- /server/SyncService.js: -------------------------------------------------------------------------------- 1 | import CalendarAPI from './api/CalendarAPI'; 2 | import CalendarStore from './CalendarStore'; 3 | const api = new CalendarAPI(); 4 | const store = new CalendarStore(); 5 | 6 | import Configstore from 'configstore'; 7 | const conf = new Configstore('menu-calendar'); 8 | 9 | import EventEmitter from 'events'; 10 | 11 | const POLL_INTERVAL = 30000; 12 | 13 | export default class extends EventEmitter { 14 | 15 | setAuth(auth) { 16 | api.setAuth(auth); 17 | } 18 | 19 | async sync() { 20 | const { syncTokens, items } = await api.syncEvents(conf.get('syncTokens') || {}); 21 | conf.set('syncTokens', syncTokens); 22 | await store.removeItems(items.remove); 23 | await store.setItems(items.save); 24 | } 25 | 26 | async getEvents() { 27 | const start = new Date(); 28 | start.setDate(start.getDate() - 30); 29 | start.setHours(0); 30 | start.setMinutes(0); 31 | const end = new Date(); 32 | end.setDate(end.getDate() + 30); 33 | 34 | console.log('Sync: #tick: Now pulling all from store'); 35 | 36 | const events = await store.getByDate(start.toISOString(), end.toISOString()); 37 | 38 | console.log('Sync: #tick: Update done, firing update:', events.length); 39 | 40 | this.emit('update', events); 41 | } 42 | 43 | async tick() { 44 | try { 45 | console.log('Sync: #tick: Starting'); 46 | 47 | await this.sync(); 48 | 49 | console.log('Sync: #tick: Synced and updated store'); 50 | 51 | await this.getEvents(); 52 | } catch (e) { 53 | console.error(e, e.stack); 54 | } 55 | 56 | this.timeout = setTimeout(this.tick.bind(this), POLL_INTERVAL); 57 | } 58 | 59 | async start() { 60 | if (this.timeout) this.stop(); 61 | 62 | console.log('Sync: #start: Start'); 63 | await this.getEvents(); 64 | this.tick(); 65 | } 66 | 67 | stop() { 68 | clearTimeout(this.timeout); 69 | } 70 | } 71 | -------------------------------------------------------------------------------- /server/api/CalendarAPI.js: -------------------------------------------------------------------------------- 1 | import Q from 'q'; 2 | import google from 'googleapis'; 3 | const gcal = google.calendar('v3'); 4 | 5 | export default class CalendarAPI { 6 | 7 | static MAX_RESULTS = 1000; 8 | 9 | 10 | /* 11 | * Store the auth client to use with api calls 12 | */ 13 | 14 | setAuth(oauth) { 15 | this.oauth = oauth; 16 | } 17 | 18 | 19 | /* 20 | * Get a list of the user's calendars 21 | */ 22 | 23 | async getCalendars() { 24 | const resp = await Q.nfcall(gcal.calendarList.list, { 25 | auth: this.oauth.client 26 | }); 27 | const data = resp[0]; 28 | return data.items; 29 | } 30 | 31 | 32 | /* 33 | * Run the event sync process 34 | * If we have a syncToken in storage, initial sync is done, so use it to fetch the changed items 35 | * If we have a pageToken in storage, we're in the middle of a sync so grab the next page 36 | */ 37 | 38 | _getQueryOptions(syncTokens) { 39 | const nextSyncToken = syncTokens.nextSyncToken; 40 | const nextPageToken = syncTokens.nextPageToken; 41 | 42 | const opts = { 43 | auth: this.oauth.client, 44 | calendarId: 'primary', 45 | maxResults: CalendarAPI.MAX_RESULTS, 46 | timeZone: 'GMT', 47 | singleEvents: true 48 | }; 49 | 50 | if (nextSyncToken) { 51 | opts.syncToken = nextSyncToken; 52 | } else if (!nextPageToken) { 53 | const start = new Date(); 54 | start.setDate(start.getDate() - 30); 55 | start.setHours(0); 56 | start.setMinutes(0); 57 | const end = new Date(); 58 | end.setDate(end.getDate() + 30); 59 | opts.timeMin = start.toISOString(); 60 | opts.timeMax = end.toISOString(); 61 | } 62 | 63 | if (nextPageToken) { 64 | opts.pageToken = nextPageToken; 65 | } 66 | 67 | return opts; 68 | } 69 | 70 | async syncEvents(syncTokens, collector = { save: [], remove: [] }) { 71 | console.log('CalendarAPI: #syncEvents: Starting sync'); 72 | 73 | const queryOpts = this._getQueryOptions(syncTokens); 74 | 75 | const resp = await Q.nfcall(gcal.events.list, queryOpts); 76 | const data = resp[0]; 77 | 78 | console.log('CalendarAPI: #syncEvents: Processing list response'); 79 | 80 | // Group items by action 81 | data.items.forEach((i) => { 82 | if (i.status === 'cancelled') { 83 | collector.remove.push(i); 84 | } else { 85 | collector.save.push(i); 86 | } 87 | }); 88 | 89 | console.log(`CalendarAPI: #syncEvents: Response: save.length = ${collector.save.length}, remove.length = ${collector.remove.length}`); 90 | 91 | // Fetch next page of results 92 | if (data.nextPageToken) { 93 | console.log('CalendarAPI: #syncEvents: Fetching next page'); 94 | syncTokens.nextPageToken = data.nextPageToken; 95 | return await this.syncEvents(syncTokens, collector); 96 | } 97 | 98 | // Finished, so return collector 99 | if (data.nextSyncToken) { 100 | console.log('CalendarAPI: #syncEvents: Finished syncing'); 101 | syncTokens.nextSyncToken = data.nextSyncToken; 102 | delete syncTokens.nextPageToken; 103 | return { 104 | syncTokens, 105 | items: collector 106 | }; 107 | } 108 | 109 | return null; 110 | } 111 | } 112 | -------------------------------------------------------------------------------- /server/api/ProfileAPI.js: -------------------------------------------------------------------------------- 1 | import Q from 'q'; 2 | import google from 'googleapis'; 3 | const gauth = google.oauth2('v2'); 4 | 5 | export default class Profile { 6 | 7 | 8 | /* 9 | * Store the auth client to use with api calls 10 | */ 11 | 12 | setAuth(oauth) { 13 | this.oauth = oauth; 14 | } 15 | 16 | 17 | /* 18 | * Use the oauth api to grab the current userinfo 19 | */ 20 | 21 | getProfile() { 22 | return Q.nfcall(gauth.userinfo.v2.me.get, { 23 | auth: this.oauth.client 24 | }).then(res => res[0]); 25 | } 26 | 27 | } 28 | -------------------------------------------------------------------------------- /server/oauth/ElectronGoogleAuth.js: -------------------------------------------------------------------------------- 1 | // Adapted from https://github.com/parro-it/electron-google-oauth 2 | import google from 'googleapis'; 3 | const GoogleOAuth2 = google.auth.OAuth2; 4 | 5 | export default class Auth { 6 | 7 | /* 8 | * Construct a new Google OAuth2 instance. 9 | * 10 | * @param {object} BrowserWindow - a require('browser-window') instance 11 | * @param {object} opts - oAuth options 12 | * @param {string} opts.clientId - oAuth client_id 13 | * @param {string} opts.clientSecret - oAuth client_secret 14 | * @param {array} opts.scopes - Array of oAuth scope strings 15 | */ 16 | 17 | constructor(opts) { 18 | this.opts = opts; 19 | this.client = new GoogleOAuth2(opts.clientId, opts.clientSecret, 'urn:ietf:wg:oauth:2.0:oob'); 20 | } 21 | 22 | 23 | /* 24 | * Open a window and get the oAuth tokens. 25 | */ 26 | 27 | async auth(BrowserWindow) { 28 | console.log('ElectronGoogleAuth: #auth: Getting auth'); 29 | 30 | const authorizationCode = await this._getAuthorizationCode(BrowserWindow); 31 | 32 | return new Promise((resolve) => ( 33 | this.client.getToken(authorizationCode, (err, tokens) => { 34 | this.client.setCredentials(tokens); 35 | resolve(tokens); 36 | }) 37 | )); 38 | } 39 | 40 | 41 | /* 42 | * Open a browser window and get the oAuth code. 43 | */ 44 | 45 | _getAuthorizationCode(BrowserWindow) { 46 | return new Promise((resolve, reject) => { 47 | const url = this.client.generateAuthUrl({ 48 | access_type: 'offline', 49 | scope: this.opts.scopes 50 | }); 51 | 52 | const win = new BrowserWindow({ 53 | 'use-content-size': true 54 | }); 55 | 56 | win.loadURL(url); 57 | 58 | win.on('closed', () => { 59 | reject(new Error('User closed the window')); 60 | }); 61 | 62 | win.on('page-title-updated', () => { 63 | setImmediate(() => { 64 | const title = win.getTitle(); 65 | if (title.startsWith('Denied')) { 66 | reject(new Error(title.split(/[ =]/)[2])); 67 | win.removeAllListeners('closed'); 68 | win.close(); 69 | } else if (title.startsWith('Success')) { 70 | resolve(title.split(/[ =]/)[2]); 71 | win.removeAllListeners('closed'); 72 | win.close(); 73 | } 74 | }); 75 | }); 76 | }); 77 | } 78 | } 79 | -------------------------------------------------------------------------------- /server/server.js: -------------------------------------------------------------------------------- 1 | import menubar from 'menubar'; 2 | import { BrowserWindow, ipcMain as ipc } from 'electron'; 3 | 4 | import Configstore from 'configstore'; 5 | import secrets from '../secrets.json'; 6 | import SyncService from './SyncService'; 7 | 8 | const conf = new Configstore('menu-calendar'); 9 | 10 | import ElectronGoogleAuth from './oauth/ElectronGoogleAuth'; 11 | 12 | const oauth = new ElectronGoogleAuth(Object.assign({}, secrets.oauth, { 13 | scopes: ['profile', 'email', 'https://www.googleapis.com/auth/calendar.readonly'] 14 | })); 15 | 16 | // conf.clear(); 17 | 18 | /* 19 | * Start up the menubar app 20 | */ 21 | 22 | const mb = menubar({ 23 | 'always-on-top': process.env.NODE_ENV === 'development', 24 | transparent: true, 25 | dir: 'client/', 26 | preloadWindow: true, 27 | height: 650, 28 | width: 320 29 | }); 30 | 31 | 32 | /* 33 | * Gets the auth token, via either: 34 | * An existing token from the conf 35 | * A new token from a new oauth browser window 36 | */ 37 | 38 | const getToken = async () => { 39 | const token = conf.get('auth'); 40 | if (token) { 41 | return token; 42 | } 43 | 44 | const result = await oauth.auth(BrowserWindow); 45 | conf.set('auth', result); 46 | return result; 47 | }; 48 | 49 | 50 | /* 51 | * Start syncing 52 | */ 53 | 54 | const start = async () => { 55 | try { 56 | const token = await getToken(); 57 | oauth.client.setCredentials(token); 58 | 59 | const sync = new SyncService(); 60 | 61 | sync.on('update', (events) => { 62 | if (mb.window) { 63 | mb.window.webContents.send('events.synced', events); 64 | } 65 | }); 66 | 67 | sync.setAuth(oauth); 68 | await sync.start(); 69 | } catch (e) { 70 | console.error(e, e.stack); 71 | } 72 | }; 73 | 74 | 75 | /* 76 | * Listen for 'auth.get' requests and fetch token. 77 | * Emit an 'auth.change' event. 78 | */ 79 | 80 | ipc.on('auth.get', async (event) => { 81 | try { 82 | const token = await getToken(); 83 | event.sender.send('auth.change', null, token); 84 | } catch (e) { 85 | event.sender.send('auth.change', e.stack, null); 86 | } 87 | }); 88 | 89 | 90 | /* 91 | * Open dev tools after window is shown 92 | */ 93 | 94 | if (process.env.NODE_ENV === 'development') { 95 | mb.on('after-show', () => { 96 | mb.window.openDevTools({ detach: true }); 97 | }); 98 | } 99 | 100 | 101 | /* 102 | * Notify app when shown 103 | */ 104 | 105 | mb.on('after-show', () => { 106 | if (mb.window) { 107 | mb.window.webContents.send('app.after-show'); 108 | } 109 | }); 110 | 111 | 112 | /* 113 | * When menubar is ready, start syncing 114 | */ 115 | 116 | mb.on('ready', async () => { 117 | await start(); 118 | }); 119 | -------------------------------------------------------------------------------- /tests/client/utils/eventUtils.js: -------------------------------------------------------------------------------- 1 | import test from 'tape'; 2 | import eventUtils from '../../../client/utils/eventUtils'; 3 | 4 | test('getEventsForDay', (t) => { 5 | t.plan(5); 6 | 7 | const e1 = { 8 | start: { date: '2016-06-06' }, 9 | end: { date: '2016-06-07' } 10 | }; 11 | 12 | const e2 = { 13 | start: { date: '2016-06-07' }, 14 | end: { date: '2016-06-08' } 15 | }; 16 | 17 | const e3 = { 18 | start: { date: '2016-06-07' }, 19 | end: { date: '2016-06-09' } 20 | }; 21 | 22 | let result = eventUtils.getEventsForDay(new Date(2016, 5, 6), [e1, e2]); 23 | t.same(result, [e1]); 24 | result = eventUtils.getEventsForDay(new Date(2016, 5, 7), [e1, e2]); 25 | t.same(result, [e2]); 26 | result = eventUtils.getEventsForDay(new Date(2016, 5, 8), [e1, e2]); 27 | t.same(result, []); 28 | result = eventUtils.getEventsForDay(new Date(2016, 5, 8), [e3]); 29 | t.same(result, [e3]); 30 | 31 | const e4 = { 32 | start: { dateTime: '2016-06-06T20:00:00Z' }, 33 | end: { dateTime: '2016-06-06T20:00:00Z' } 34 | }; 35 | 36 | result = eventUtils.getEventsForDay(new Date(2016, 5, 6), [e4]); 37 | t.same(result, [e4]); 38 | }); 39 | 40 | test('getDatesForEvent', (t) => { 41 | t.plan(2); 42 | 43 | const e1 = { 44 | start: { date: '2016-06-07' }, 45 | end: { date: '2016-06-09' } 46 | }; 47 | 48 | let result = eventUtils.getDatesForEvent(e1).map(d => d.getTime()); 49 | t.same(result, [ 50 | new Date(2016, 5, 7).getTime(), 51 | new Date(2016, 5, 8).getTime() 52 | ]); 53 | 54 | const e2 = { 55 | start: { date: '2016-06-07' }, 56 | end: { date: '2016-06-08' } 57 | }; 58 | 59 | result = eventUtils.getDatesForEvent(e2).map(d => d.getTime()); 60 | t.same(result, [ 61 | new Date(2016, 5, 7).getTime() 62 | ]); 63 | }); 64 | 65 | test('getEventStartDate', (t) => { 66 | t.plan(1); 67 | const e = { 68 | start: { date: '2016-06-06' }, 69 | end: { date: '2016-06-07' } 70 | }; 71 | 72 | const expected = new Date(2016, 5, 6); 73 | const result = eventUtils.getEventStartDate(e); 74 | t.equal(expected.getTime(), result.getTime()); 75 | }); 76 | 77 | test('getEventEndDate', (t) => { 78 | t.plan(2); 79 | const e = { 80 | start: { date: '2016-06-06' }, 81 | end: { date: '2016-06-07' } 82 | }; 83 | 84 | const expected = new Date(2016, 5, 6); 85 | const result = eventUtils.getEventEndDate(e); 86 | t.equal(expected.getTime(), result.getTime()); 87 | 88 | const e2 = { 89 | start: { dateTime: '2016-06-06T20:00:00Z' }, 90 | end: { dateTime: '2016-06-06T20:00:00Z' } 91 | }; 92 | 93 | t.equal(new Date('2016-06-06T20:00:00Z').getTime(), eventUtils.getEventEndDate(e2).getTime()); 94 | }); 95 | 96 | test('groupEventsByDate', (t) => { 97 | t.plan(1); 98 | const e1 = { 99 | start: { date: '2016-06-06' }, 100 | end: { date: '2016-06-08' } 101 | }; 102 | const result = eventUtils.groupEventsByDate([e1], new Date(2016, 5, 7)); 103 | t.same({ 104 | 'Monday 6/6/16': [e1], 105 | 'Today 6/7/16': [e1] 106 | }, result); 107 | }); 108 | -------------------------------------------------------------------------------- /tests/client/utils/timeUtils.js: -------------------------------------------------------------------------------- 1 | /* eslint no-shadow: 0 */ 2 | 3 | import tape from 'tape'; 4 | import timeUtils from '../../../client/utils/timeUtils'; 5 | 6 | tape('isBetween', (t) => { 7 | const start = new Date(2016, 5, 6); 8 | const end = new Date(2016, 5, 7); 9 | 10 | t.test('same day range', (t) => { 11 | t.plan(2); 12 | t.equal(true, timeUtils.isBetween(new Date(2016, 5, 6), start, start)); 13 | t.equal(false, timeUtils.isBetween(new Date(2016, 5, 7), start, start)); 14 | }); 15 | 16 | t.test('two day range', (t) => { 17 | t.plan(4); 18 | t.equal(true, timeUtils.isBetween(new Date(2016, 5, 6), start, end)); 19 | t.equal(true, timeUtils.isBetween(new Date(2016, 5, 7), start, end)); 20 | t.equal(false, timeUtils.isBetween(new Date(2016, 5, 8), start, end)); 21 | t.equal(false, timeUtils.isBetween(new Date(2016, 5, 5), start, end)); 22 | }); 23 | 24 | t.test('three day range', (t) => { 25 | t.plan(5); 26 | const end2 = new Date(2016, 5, 8); 27 | t.equal(true, timeUtils.isBetween(new Date(2016, 5, 6), start, end2)); 28 | t.equal(true, timeUtils.isBetween(new Date(2016, 5, 7), start, end2)); 29 | t.equal(true, timeUtils.isBetween(new Date(2016, 5, 8), start, end2)); 30 | t.equal(false, timeUtils.isBetween(new Date(2016, 5, 5), start, end2)); 31 | t.equal(false, timeUtils.isBetween(new Date(2016, 5, 9), start, end2)); 32 | }); 33 | }); 34 | --------------------------------------------------------------------------------