Sync with Google calendar

This commit is contained in:
Ben Grant 2021-05-16 18:17:39 +10:00
parent 7a52ff4fdb
commit 8b24b2e27a
10 changed files with 404 additions and 78 deletions

View file

@ -14,6 +14,7 @@
"@types/react-dom": "^17.0.1", "@types/react-dom": "^17.0.1",
"axios": "^0.21.1", "axios": "^0.21.1",
"dayjs": "^1.10.4", "dayjs": "^1.10.4",
"gapi-script": "^1.2.0",
"react": "^17.0.1", "react": "^17.0.1",
"react-dom": "^17.0.1", "react-dom": "^17.0.1",
"react-hook-form": "^6.15.4", "react-hook-form": "^6.15.4",

View file

@ -2,9 +2,11 @@ import { useState, useRef, Fragment } from 'react';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import localeData from 'dayjs/plugin/localeData'; import localeData from 'dayjs/plugin/localeData';
import customParseFormat from 'dayjs/plugin/customParseFormat'; import customParseFormat from 'dayjs/plugin/customParseFormat';
import isBetween from 'dayjs/plugin/isBetween';
import { import {
Wrapper, Wrapper,
ScrollWrapper,
Container, Container,
Date, Date,
Times, Times,
@ -14,16 +16,21 @@ import {
TimeLabels, TimeLabels,
TimeLabel, TimeLabel,
TimeSpace, TimeSpace,
StyledMain,
} from 'components/AvailabilityViewer/availabilityViewerStyle'; } from 'components/AvailabilityViewer/availabilityViewerStyle';
import { Time } from './availabilityEditorStyle'; import { Time } from './availabilityEditorStyle';
import { GoogleCalendar } from 'components';
dayjs.extend(localeData); dayjs.extend(localeData);
dayjs.extend(customParseFormat); dayjs.extend(customParseFormat);
dayjs.extend(isBetween);
const AvailabilityEditor = ({ const AvailabilityEditor = ({
times, times,
timeLabels, timeLabels,
dates, dates,
timezone,
isSpecificDates, isSpecificDates,
value = [], value = [],
onChange, onChange,
@ -45,82 +52,101 @@ const AvailabilityEditor = ({
}; };
return ( return (
<Wrapper> <>
<Container> {isSpecificDates && (
<TimeLabels> <StyledMain>
{!!timeLabels.length && timeLabels.map((label, i) => <GoogleCalendar
<TimeSpace key={i}> timeMin={dayjs(times[0], 'HHmm-DDMMYYYY').toISOString()}
{label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>} timeMax={dayjs(times[times.length-1], 'HHmm-DDMMYYYY').add(15, 'm').toISOString()}
</TimeSpace> timeZone={timezone}
)} onImport={busyArray => onChange(
</TimeLabels> times.filter(time => !busyArray.some(busy =>
{dates.map((date, x) => { dayjs(time, 'HHmm-DDMMYYYY').isBetween(busy.start, busy.end, null, '[)')
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date); ))
const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1; )}
return ( />
<Fragment key={x}> </StyledMain>
<Date> )}
{isSpecificDates && <DateLabel>{parsedDate.format('MMM D')}</DateLabel>}
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
<Times> <Wrapper>
{timeLabels.map((timeLabel, y) => { <ScrollWrapper>
if (!timeLabel.time) return null; <Container>
if (!times.includes(`${timeLabel.time}-${date}`)) { <TimeLabels>
return ( {!!timeLabels.length && timeLabels.map((label, i) =>
<TimeSpace key={x+y} /> <TimeSpace key={i}>
); {label.label?.length !== '' && <TimeLabel>{label.label}</TimeLabel>}
} </TimeSpace>
const time = `${timeLabel.time}-${date}`; )}
</TimeLabels>
{dates.map((date, x) => {
const parsedDate = isSpecificDates ? dayjs(date, 'DDMMYYYY') : dayjs().day(date);
const last = dates.length === x+1 || (isSpecificDates ? dayjs(dates[x+1], 'DDMMYYYY') : dayjs().day(dates[x+1])).diff(parsedDate, 'day') > 1;
return (
<Fragment key={x}>
<Date>
{isSpecificDates && <DateLabel>{parsedDate.format('MMM D')}</DateLabel>}
<DayLabel>{parsedDate.format('ddd')}</DayLabel>
return ( <Times>
<Time {timeLabels.map((timeLabel, y) => {
key={x+y} if (!timeLabel.time) return null;
time={time} if (!times.includes(`${timeLabel.time}-${date}`)) {
className="time" return (
selected={value.includes(time)} <TimeSpace key={x+y} />
selecting={selectingTimes.includes(time)} );
mode={mode} }
onPointerDown={(e) => { const time = `${timeLabel.time}-${date}`;
e.preventDefault();
startPos.current = {x, y};
setMode(value.includes(time) ? 'remove' : 'add');
setSelectingTimes([time]);
e.currentTarget.releasePointerCapture(e.pointerId);
document.addEventListener('pointerup', () => { return (
if (staticMode.current === 'add') { <Time
onChange([...value, ...staticSelectingTimes.current]); key={x+y}
} else if (staticMode.current === 'remove') { time={time}
onChange(value.filter(t => !staticSelectingTimes.current.includes(t))); className="time"
} selected={value.includes(time)}
setMode(null); selecting={selectingTimes.includes(time)}
}, { once: true }); mode={mode}
}} onPointerDown={(e) => {
onPointerEnter={() => { e.preventDefault();
if (staticMode.current) { startPos.current = {x, y};
let found = []; setMode(value.includes(time) ? 'remove' : 'add');
for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) { setSelectingTimes([time]);
for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) { e.currentTarget.releasePointerCapture(e.pointerId);
found.push({y: cy, x: cx});
} document.addEventListener('pointerup', () => {
} if (staticMode.current === 'add') {
setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`)); onChange([...value, ...staticSelectingTimes.current]);
} } else if (staticMode.current === 'remove') {
}} onChange(value.filter(t => !staticSelectingTimes.current.includes(t)));
/> }
); setMode(null);
})} }, { once: true });
</Times> }}
</Date> onPointerEnter={() => {
{last && dates.length !== x+1 && ( if (staticMode.current) {
<Spacer /> let found = [];
)} for (let cy = Math.min(startPos.current.y, y); cy < Math.max(startPos.current.y, y)+1; cy++) {
</Fragment> for (let cx = Math.min(startPos.current.x, x); cx < Math.max(startPos.current.x, x)+1; cx++) {
); found.push({y: cy, x: cx});
})} }
</Container> }
</Wrapper> setSelectingTimes(found.filter(d => timeLabels[d.y].time?.length === 4).map(d => `${timeLabels[d.y].time}-${dates[d.x]}`));
}
}}
/>
);
})}
</Times>
</Date>
{last && dates.length !== x+1 && (
<Spacer />
)}
</Fragment>
);
})}
</Container>
</ScrollWrapper>
</Wrapper>
</>
); );
}; };

View file

@ -3,11 +3,13 @@ import { Wrapper, Top, Bottom } from './buttonStyle';
const Button = ({ const Button = ({
buttonHeight, buttonHeight,
buttonWidth, buttonWidth,
primaryColor,
secondaryColor,
...props ...props
}) => ( }) => (
<Wrapper buttonHeight={buttonHeight} buttonWidth={buttonWidth}> <Wrapper buttonHeight={buttonHeight} buttonWidth={buttonWidth}>
<Top {...props} /> <Top primaryColor={primaryColor} secondaryColor={secondaryColor} {...props} />
<Bottom /> <Bottom secondaryColor={secondaryColor} />
</Wrapper> </Wrapper>
); );

View file

@ -16,10 +16,10 @@ export const Top = styled.button`
cursor: pointer; cursor: pointer;
font: inherit; font: inherit;
box-sizing: border-box; box-sizing: border-box;
background: ${props => props.theme.primary}; background: ${props => props.primaryColor || props.theme.primary};
color: #FFF; color: #FFF;
font-weight: 600; font-weight: 600;
text-shadow: 0 -1.5px .5px ${props => props.theme.primaryDark}; text-shadow: 0 -1.5px .5px ${props => props.secondaryColor || props.theme.primaryDark};
padding: 0; padding: 0;
border-radius: 3px; border-radius: 3px;
height: var(--btn-height); height: var(--btn-height);
@ -43,6 +43,10 @@ export const Top = styled.button`
color: transparent; color: transparent;
cursor: wait; cursor: wait;
& img {
opacity: 0;
}
@keyframes load { @keyframes load {
from { from {
transform: rotate(0deg); transform: rotate(0deg);
@ -69,7 +73,7 @@ export const Top = styled.button`
export const Bottom = styled.div` export const Bottom = styled.div`
box-sizing: border-box; box-sizing: border-box;
background: ${props => props.theme.primaryDark}; background: ${props => props.secondaryColor || props.theme.primaryDark};
border-radius: 3px; border-radius: 3px;
height: var(--btn-height); height: var(--btn-height);
width: var(--btn-width); width: var(--btn-width);

View file

@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import { loadGapiInsideDOM } from 'gapi-script';
import { Button, Center } from 'components';
import { Loader } from '../Loading/loadingStyle';
import {
LoginButton,
CalendarList,
CheckboxInput,
CheckboxLabel,
CalendarLabel,
Info,
Options,
} from './googleCalendarStyle';
import googleLogo from 'res/google.svg';
const signIn = () => window.gapi.auth2.getAuthInstance().signIn();
const signOut = () => window.gapi.auth2.getAuthInstance().signOut();
const GoogleCalendar = ({ timeZone, timeMin, timeMax, onImport }) => {
const [signedIn, setSignedIn] = useState(undefined);
const [calendars, setCalendars] = useState(undefined);
const [freeBusyLoading, setFreeBusyLoading] = useState(false);
const calendarLogin = async () => {
const gapi = await loadGapiInsideDOM();
gapi.load('client:auth2', () => {
window.gapi.client.init({
clientId: '276505195333-9kjl7e48m272dljbspkobctqrpet0n8m.apps.googleusercontent.com',
discoveryDocs: ['https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest'],
scope: 'https://www.googleapis.com/auth/calendar.readonly',
})
.then(() => {
// Listen for state changes
window.gapi.auth2.getAuthInstance().isSignedIn.listen(isSignedIn => setSignedIn(isSignedIn));
// Handle initial sign-in state
setSignedIn(window.gapi.auth2.getAuthInstance().isSignedIn.get());
})
.catch(e => {
console.error(e);
setSignedIn(false);
});
});
};
const importAvailability = () => {
setFreeBusyLoading(true);
window.gapi.client.calendar.freebusy.query({
timeMin,
timeMax,
timeZone,
items: calendars.filter(c => c.checked).map(c => ({id: c.id})),
})
.then(response => {
onImport(response.result.calendars ? Object.values(response.result.calendars).reduce((busy, c) => [...busy, ...c.busy], []) : []);
setFreeBusyLoading(false);
}, e => {
console.error(e);
setFreeBusyLoading(false);
});
};
useEffect(() => calendarLogin(), []);
useEffect(() => {
if (signedIn) {
window.gapi.client.calendar.calendarList.list({
'minAccessRole': 'freeBusyReader'
})
.then(response => {
setCalendars(response.result.items.map(item => ({
'name': item.summary,
'description': item.description,
'id': item.id,
'color': item.backgroundColor,
'checked': item.hasOwnProperty('primary') && item.primary === true,
})));
})
.catch(e => {
console.error(e);
signOut();
});
}
}, [signedIn]);
return (
<>
{!signedIn ? (
<Center>
<Button
onClick={() => signIn()}
isLoading={signedIn === undefined}
buttonWidth="270px"
primaryColor="#4286F5"
secondaryColor="#3367BD">
<LoginButton>
<img src={googleLogo} alt="" />
<span>Sync with Google Calendar</span>
</LoginButton>
</Button>
</Center>
) : (
<CalendarList>
<p>
{/* eslint-disable-next-line */}
<strong>Sync with Google Calendar</strong> (<a href="#" onClick={e => {
e.preventDefault();
signOut();
}}>log out</a>)
</p>
<Options>
{calendars !== undefined && !calendars.every(c => c.checked) && (
/* eslint-disable-next-line */
<a href="#" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: true})));
}}>Select all</a>
)}
{calendars !== undefined && calendars.every(c => c.checked) && (
/* eslint-disable-next-line */
<a href="#" onClick={e => {
e.preventDefault();
setCalendars(calendars.map(c => ({...c, checked: false})));
}}>Select none</a>
)}
</Options>
{calendars !== undefined ? calendars.map(calendar => (
<div key={calendar.id}>
<CheckboxInput
type="checkbox"
role="checkbox"
id={calendar.id}
color={calendar.color}
checked={calendar.checked}
onChange={e => setCalendars(calendars.map(c => c.id === calendar.id ? {...c, checked: !c.checked} : c))}
/>
<CheckboxLabel htmlFor={calendar.id} color={calendar.color} />
<CalendarLabel htmlFor={calendar.id}>{calendar.name}</CalendarLabel>
</div>
)) : (
<Loader />
)}
{calendars !== undefined && (
<>
<Info>Importing will overwrite your currently inputted availability</Info>
<Button
buttonWidth="170px"
buttonHeight="35px"
isLoading={freeBusyLoading}
onClick={() => importAvailability()}
>Import availability</Button>
</>
)}
</CalendarList>
)}
</>
);
};
export default GoogleCalendar;

View file

@ -0,0 +1,112 @@
import styled from '@emotion/styled';
export const LoginButton = styled.div`
display: flex;
align-items: center;
justify-content: center;
& img {
height: 1em;
margin-right: .8em;
}
`;
export const CalendarList = styled.div`
& > div {
display: flex;
margin: 2px 0;
}
`;
export const CheckboxInput = styled.input`
height: 0px;
width: 0px;
margin: 0;
padding: 0;
border: 0;
background: 0;
font-size: 0;
transform: scale(0);
position: absolute;
&:checked + label::after {
opacity: 1;
transform: scale(1);
}
&[disabled] + label {
opacity: .6;
}
&[disabled] + label:after {
border: 2px solid ${props => props.theme.text};
background-color: ${props => props.theme.text};
}
&:focus + label {
box-shadow: 0 0 0 2px ${props => props.theme.text}44;
background-color: ${props => props.theme.text}44;
outline: none;
}
&:checked:focus + label {
box-shadow: 0 0 0 2px ${props => props.color || props.theme.primary}44;
background-color: ${props => props.color || props.theme.primary}44;
}
`;
export const CheckboxLabel = styled.label`
display: inline-block;
height: 24px;
width: 24px;
min-width: 24px;
position: relative;
border-radius: 3px;
transition: background-color 0.2s, box-shadow 0.2s;
&::before {
content: '';
display: inline-block;
height: 14px;
width: 14px;
border: 2px solid ${props => props.theme.text};
border-radius: 2px;
position: absolute;
top: 3px;
left: 3px;
}
&::after {
content: '';
display: inline-block;
height: 14px;
width: 14px;
border: 2px solid ${props => props.color || props.theme.primary};
background-color: ${props => props.color || props.theme.primary};
border-radius: 2px;
position: absolute;
top: 3px;
left: 3px;
background-image: url('data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZlcnNpb249IjEuMSIgIHdpZHRoPSIyNCIgaGVpZ2h0PSIyNCIgdmlld0JveD0iMCAwIDI0IDI0Ij48cGF0aCBmaWxsPSIjRkZGRkZGIiBkPSJNMjEsN0w5LDE5TDMuNSwxMy41TDQuOTEsMTIuMDlMOSwxNi4xN0wxOS41OSw1LjU5TDIxLDdaIiAvPjwvc3ZnPg==');
background-size: 16px;
background-position: center;
background-repeat: no-repeat;
opacity: 0;
transform: scale(.5);
transition: opacity 0.15s, transform 0.15s;
}
`;
export const CalendarLabel = styled.label`
margin-left: .6em;
font-size: 15px;
font-weight: 500;
line-height: 24px;
`;
export const Info = styled.div`
font-size: 14px;
opacity: .6;
font-weight: 500;
padding: 14px 0 10px;
`;
export const Options = styled.div`
font-size: 14px;
padding: 0 0 5px;
`;

View file

@ -10,6 +10,7 @@ export { default as AvailabilityViewer } from './AvailabilityViewer/Availability
export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor'; export { default as AvailabilityEditor } from './AvailabilityEditor/AvailabilityEditor';
export { default as Error } from './Error/Error'; export { default as Error } from './Error/Error';
export { default as Loading } from './Loading/Loading'; export { default as Loading } from './Loading/Loading';
export { default as GoogleCalendar } from './GoogleCalendar/GoogleCalendar';
export { default as Center } from './Center/Center'; export { default as Center } from './Center/Center';
export { default as Donate } from './Donate/Donate'; export { default as Donate } from './Donate/Donate';

View file

@ -370,6 +370,7 @@ const Event = (props) => {
onChange={event => setTimezone(event.currentTarget.value)} onChange={event => setTimezone(event.currentTarget.value)}
options={timezones} options={timezones}
/> />
{/* eslint-disable-next-line */}
{event?.timezone && event.timezone !== timezone && <p>This event was created in the timezone <strong>{event.timezone}</strong>. <a href="#" onClick={e => { {event?.timezone && event.timezone !== timezone && <p>This event was created in the timezone <strong>{event.timezone}</strong>. <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setTimezone(event.timezone); setTimezone(event.timezone);
@ -381,6 +382,7 @@ const Event = (props) => {
event?.timezone === undefined event?.timezone === undefined
&& Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone && Intl.DateTimeFormat().resolvedOptions().timeZone !== timezone
)) && ( )) && (
/* eslint-disable-next-line */
<p>Your local timezone is detected to be <strong>{Intl.DateTimeFormat().resolvedOptions().timeZone}</strong>. <a href="#" onClick={e => { <p>Your local timezone is detected to be <strong>{Intl.DateTimeFormat().resolvedOptions().timeZone}</strong>. <a href="#" onClick={e => {
e.preventDefault(); e.preventDefault();
setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone); setTimezone(Intl.DateTimeFormat().resolvedOptions().timeZone);
@ -435,6 +437,7 @@ const Event = (props) => {
times={times} times={times}
timeLabels={timeLabels} timeLabels={timeLabels}
dates={dates} dates={dates}
timezone={timezone}
isSpecificDates={!!dates.length && dates[0].length === 8} isSpecificDates={!!dates.length && dates[0].length === 8}
value={user.availability} value={user.availability}
onChange={async availability => { onChange={async availability => {

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 2443 2500" style="enable-background:new 0 0 2443 2500;" xml:space="preserve">
<path style="fill:#FFFFFF" d="M1245.8,1018.7V1481h686.5c-13.8,114.9-88.6,287.9-254.7,404.2v0c-105.2,73.4-246.4,124.6-431.8,124.6
c-329.4,0-609-217.3-708.7-517.7h0l0,0v0c-26.3-77.5-41.5-160.6-41.5-246.4c0-85.8,15.2-168.9,40.1-246.4v0
c101-300.4,380.6-517.7,710.1-517.7v0c233.9,0,391.7,101,481.7,185.5l351.6-343.3C1863.2,123.2,1582.2,0,1245.8,0
C758.6,0,337.8,279.6,133,686.5h0h0C48.6,855.4,0.1,1045,0.1,1245.7S48.6,1636,133,1804.9h0l0,0
c204.8,406.9,625.6,686.5,1112.8,686.5c336.3,0,618.7-110.7,824.9-301.7l0,0h0c235.3-217.3,370.9-537,370.9-916.3
c0-102.4-8.3-177.2-26.3-254.7H1245.8z"/>
</svg>

After

Width:  |  Height:  |  Size: 854 B

View file

@ -5273,6 +5273,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
gapi-script@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/gapi-script/-/gapi-script-1.2.0.tgz#8106ad0abb36661ce4fab62ef6efada288d7169e"
integrity sha512-NKTVKiIwFdkO1j1EzcrWu/Pz7gsl1GmBmgh+qhuV2Ytls04W/Eg5aiBL91SCiBM9lU0PMu7p1hTVxhh1rPT5Lw==
gensync@^1.0.0-beta.1: gensync@^1.0.0-beta.1:
version "1.0.0-beta.2" version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"