diff --git a/src/components/NewUI/Event.tsx b/src/components/NewUI/Event.tsx index b6804cc..3caef51 100644 --- a/src/components/NewUI/Event.tsx +++ b/src/components/NewUI/Event.tsx @@ -1,4 +1,6 @@ -import { useState } from 'react'; +import { useEffect, useRef, useState } from 'react'; +import { post } from './api'; +import { green, lightgrey } from './colors'; import latlongdist, { R_miles } from './latlongdist'; import UIButton from './UIButton'; import UIPlacesAutocomplete from './UIPlacesAutocomplete'; @@ -8,9 +10,6 @@ import usePlace from './usePlace'; import useThrottle from './useThrottle'; import useToggle from './useToggle'; -const green = '#60f760'; -const lightgrey = '#e0e0e0'; - export type IEvent = { id: number; name: string; @@ -18,6 +17,8 @@ export type IEvent = { formattedAddress: string; startTime: string; endTime: string; + latitude: number; + longitude: number; }; function formatStartAndEndTime( @@ -206,13 +207,13 @@ const dummyPeopleData: IPerson[] = [ longitude: 10.12, }, ]; -function People({ event, placeId }: { event: IEvent; placeId: string }) { +function People({ event, placeId }: { event: IEvent; placeId: string | null }) { const PADDING = '1rem'; // eslint-disable-next-line const [people, setPeople] = useState(dummyPeopleData); const placeDetails = usePlace(placeId); - const myLatitude = 10; - const myLongitude = 10; + const locationLongitude = event.latitude; + const locationLatitude = event.longitude; return ( <div style={{ display: 'flex', flexDirection: 'column' }}> @@ -220,28 +221,28 @@ function People({ event, placeId }: { event: IEvent; placeId: string }) { {people.map(({ name, latitude, longitude, id }) => { let extraDistance = null; if (placeDetails != null) { - const locationLatitude = placeDetails.latitude; - const locationLongitude = placeDetails.longitude; + const myLatitude = placeDetails.latitude; + const myLongitude = placeDetails.longitude; const meToThem = latlongdist( latitude, longitude, - myLatitude, - myLongitude, + locationLongitude, + locationLatitude, R_miles ); const themToLocation = latlongdist( latitude, longitude, - locationLatitude, - locationLongitude, + myLatitude, + myLongitude, R_miles ); const totalWithThem = meToThem + themToLocation; const totalWithoutThem = latlongdist( + locationLongitude, + locationLatitude, myLatitude, myLongitude, - locationLatitude, - locationLongitude, R_miles ); extraDistance = totalWithThem - totalWithoutThem; @@ -285,9 +286,47 @@ function People({ event, placeId }: { event: IEvent; placeId: string }) { export default function Event({ event }: { event: IEvent }) { const { name, group, formattedAddress, startTime, endTime } = event; const [haveRide, toggleHaveRide] = useToggle(false); - const [placeId, setPlaceId] = useState<string>(null!); + const [placeId, setPlaceId] = useState<string | null>(null); const [interested, toggleInterested] = useToggle(false); + const [updating, setUpdating] = useState(false); const toggleInterestedThrottled = useThrottle(toggleInterested, 500); + const existingSignup = useRef({ + interested: false, + placeId: null as string | null, + eventId: null as number | null, + }); + + useEffect(() => { + const prev = existingSignup.current; + if (prev.interested === false && interested === false) { + return; + } + + if ( + (prev.interested === true && interested === false) || + (interested === true && prev.placeId !== null && placeId === null) + ) { + fetch(`http://localhost:5000/api/events/${event.id}/signup`, { + method: 'delete', + }).finally(() => setUpdating(false)); + prev.interested = false; + return; + } + + if ( + interested === true && + (prev.placeId !== placeId || prev.eventId !== event.id) + ) { + prev.placeId = placeId; + prev.eventId = event.id; + prev.interested = true; + + post(`/events/${event.id}/signup`, { + placeId, + }).finally(() => setUpdating(false)); + return; + } + }, [event.id, interested, placeId, updating]); return ( <UISecondaryBox> diff --git a/src/components/NewUI/EventCreator.tsx b/src/components/NewUI/EventCreator.tsx index 6a2f6af..2b1a2f0 100644 --- a/src/components/NewUI/EventCreator.tsx +++ b/src/components/NewUI/EventCreator.tsx @@ -1,14 +1,101 @@ -import { useCallback, useState } from 'react'; +import { Dispatch, SetStateAction, useCallback, useState } from 'react'; import { post } from './api'; +import { toggleBit } from './bits'; +import { green, lightgrey } from './colors'; import { IGroup } from './Group'; import UIButton from './UIButton'; +import UIDateInput from './UIDateInput'; import UIDatetimeInput from './UIDatetimeInput'; import UIPlacesAutocomplete from './UIPlacesAutocomplete'; import UISecondaryBox from './UISecondaryBox'; import UITextInput from './UITextInput'; +import useToggle from './useToggle'; const noop = () => {}; +const DAY_NAMES = [ + 'Sunday', + 'Monday', + 'Tuesday', + 'Wednesday', + 'Thursday', + 'Friday', + 'Saturday', +]; + +function DaysOfWeekSelector({ + daysOfWeek, + update, + disabled = false, +}: { + daysOfWeek: number; + update: Dispatch<SetStateAction<number>>; + disabled?: boolean; +}) { + const toggleDayOfWeek = useCallback( + function (idx: 1 | 2 | 3 | 4 | 5 | 6 | 7) { + update((daysOfWeek) => toggleBit(daysOfWeek, idx)); + }, + [update] + ); + + return ( + <div + style={{ + display: 'flex', + flexDirection: 'row', + margin: '1rem auto', + }} + > + {DAY_NAMES.map((name, idx) => { + const mask = 0b1000_0000 >> (idx + 1); + const active = (daysOfWeek & mask) !== 0; + return ( + <div + style={{ + borderRadius: '100%', + cursor: 'pointer', + backgroundColor: active + ? disabled + ? // lighter version of green + 'rgba(96, 247, 96, 0.5)' + : green + : disabled + ? // lighter version of lightgrey + 'rgba(224, 224, 224, 0.5)' + : lightgrey, + color: active + ? 'white' + : disabled + ? 'rgba(0, 0, 0, 0.5)' + : 'black', + userSelect: 'none', + width: '2em', + height: '2em', + margin: '0.5rem', + display: 'flex', + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + }} + onClick={() => { + if (!disabled) { + toggleDayOfWeek( + // @ts-ignore + idx + 1 + ); + } + }} + key={name} + > + {name.charAt(0)} + </div> + ); + })} + </div> + ); +} + export default function EventCreator({ group }: { group: IGroup }) { const [name, setName] = useState(''); const [startTime, setStartTime] = useState<Date | null>(null); @@ -17,22 +104,44 @@ export default function EventCreator({ group }: { group: IGroup }) { const [creating, setCreating] = useState(false); const [createdEventId, setCreatedEventId] = useState(-1); + const [recurring, toggleRecurring] = useToggle(false); + const [daysOfWeek, setDaysOfWeek] = useState(0); + const [endDate, setEndDate] = useState<Date | null>(null); + + const durationIsNegative = + endTime && startTime && endTime.getTime() < startTime.getTime(); + const buttonEnabled = name.length > 0 && startTime != null && endTime != null && placeId != null && + (!recurring || daysOfWeek || endDate !== null) && + !durationIsNegative && !creating; const createEvent = useCallback(() => { if (!creating) { + if (startTime === null) { + console.warn( + 'Tried to create an event where the start time was unspecified.' + ); + return; + } + + const duration = + endTime !== null ? (endTime.getTime() - startTime.getTime()) / 60 : 0; + setCreating(true); + post('/events', { name, startTime, - endTime, + duration, + endDate, groupId: group.id, placeId, + daysOfWeek, }) .then((response) => response.json()) .then(({ id }) => { @@ -40,7 +149,16 @@ export default function EventCreator({ group }: { group: IGroup }) { }) .finally(() => setCreating(false)); } - }, [creating, name, startTime, endTime, group.id, placeId]); + }, [ + creating, + name, + startTime, + endTime, + group.id, + placeId, + daysOfWeek, + endDate, + ]); return ( <UISecondaryBox style={{ width: '100%', boxSizing: 'border-box' }}> @@ -63,6 +181,34 @@ export default function EventCreator({ group }: { group: IGroup }) { setPlaceId(placeId); }} /> + <UIButton + onClick={toggleRecurring} + style={{ + backgroundColor: recurring ? green : lightgrey, + color: recurring ? 'white' : 'black', + transition: 'color 0.2s, background-color 0.2s', + marginBottom: '1.5rem', + }} + > + Recurring event + </UIButton> + {recurring && ( + <> + Days of week + <DaysOfWeekSelector + daysOfWeek={daysOfWeek} + update={setDaysOfWeek} + disabled={creating} + /> + Date of last occurence + <UIDateInput onChangedDate={setEndDate} disabled={creating} /> + </> + )} + {durationIsNegative && ( + <span style={{ marginTop: '1rem' }}> + The start time can't be after the end time. + </span> + )} {createdEventId === -1 ? ( <UIButton onClick={buttonEnabled ? createEvent : noop} diff --git a/src/components/NewUI/UIDateInput.tsx b/src/components/NewUI/UIDateInput.tsx new file mode 100644 index 0000000..8780274 --- /dev/null +++ b/src/components/NewUI/UIDateInput.tsx @@ -0,0 +1,44 @@ +import { useCallback } from 'react'; + +const baseStyle = { + marginTop: '0.5em', + padding: '0.5em', + fontFamily: 'Inter', + fontSize: '1.25rem', + borderRadius: '0.5em', + border: '0px', +}; + +export default function UIDateInput({ + onChangedDate, + disabled = false, +}: { + onChangedDate: (date: Date | null) => void; + disabled?: boolean; +}) { + const onChange = useCallback( + (e) => { + // YYYY-MM-DD or "" (empty string) + const dateAsString = e.target.value as string; + if (typeof dateAsString !== 'string' || dateAsString.length === 0) { + onChangedDate(null); + } + const year = dateAsString.slice(0, 4); + const month = dateAsString.slice(5, 7); + const day = dateAsString.slice(8, 10); + + // Midnight in the timezone of the user + const date = new Date(`${year}-${month}-${day}T00:00:00`); + onChangedDate(date); + }, + [onChangedDate] + ); + return ( + <input + style={baseStyle} + type="date" + disabled={disabled} + onChange={onChange} + /> + ); +} diff --git a/src/components/NewUI/api.ts b/src/components/NewUI/api.ts index 81168bf..10f5123 100644 --- a/src/components/NewUI/api.ts +++ b/src/components/NewUI/api.ts @@ -7,3 +7,12 @@ export function post(path: string, data: any) { }, }); } + +export function delete$(path: string, data: any) { + return fetch('http://localhost:5000/api' + path, { + method: 'delete', + headers: { + 'Content-Type': 'application/json', + }, + }); +} diff --git a/src/components/NewUI/bits.ts b/src/components/NewUI/bits.ts new file mode 100644 index 0000000..a4959f4 --- /dev/null +++ b/src/components/NewUI/bits.ts @@ -0,0 +1,23 @@ +export function setBit(n: number, idx: number, active: boolean) { + if (idx < 1 || idx > 7 || !isFinite(idx)) { + throw new Error('invalid idx. idx must be from 1 - 7.'); + } + + const mask = 0b1000_0000 >> idx; + + if (active) { + return n | mask; + } else { + return n & ~mask; + } +} + +export function toggleBit(n: number, idx: number) { + if (idx < 1 || idx > 7 || !isFinite(idx)) { + throw new Error('invalid idx. idx must be from 1 - 7.'); + } + + const mask = 0b1000_0000 >> idx; + + return n ^ mask; +} diff --git a/src/components/NewUI/colors.ts b/src/components/NewUI/colors.ts new file mode 100644 index 0000000..c69ddb8 --- /dev/null +++ b/src/components/NewUI/colors.ts @@ -0,0 +1,2 @@ +export const green = '#60f760'; +export const lightgrey = '#e0e0e0'; diff --git a/src/components/NewUI/usePlace.ts b/src/components/NewUI/usePlace.ts index ffbc0fb..9d17cff 100644 --- a/src/components/NewUI/usePlace.ts +++ b/src/components/NewUI/usePlace.ts @@ -2,7 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { getPlaceDetails, PlaceDetails } from '../../api/google'; import useThrottle from './useThrottle'; -export default function usePlace(placeId: string) { +export default function usePlace(placeId: string | null) { const [placeDetails, setPlaceDetails] = useState<PlaceDetails | null>(null); const updatePlaceDetails = useCallback(() => {