├── .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 |
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 |
--------------------------------------------------------------------------------