diff --git a/.env b/.env index 9643b21..c3f11e3 100644 --- a/.env +++ b/.env @@ -1,2 +1,3 @@ REACT_APP_API_LOCAL=http://localhost:5000/ +REACT_APP_API_LOCAL0=https://api.wheelshare.app/ REACT_APP_API_PROD=https://api.wheelshare.app/ diff --git a/package.json b/package.json index 1dfa54a..7de87da 100644 --- a/package.json +++ b/package.json @@ -8,12 +8,14 @@ "@testing-library/jest-dom": "^5.11.4", "@testing-library/react": "^11.1.0", "@testing-library/user-event": "^12.1.10", + "@types/google-maps-react": "^2.0.5", "@types/immutable": "^3.8.7", "@types/node": "^14.14.37", "@types/react": "^17.0.3", "@types/react-dom": "^17.0.9", "@types/react-router-dom": "^5.1.7", "dotenv": "^8.2.0", + "google-maps-react": "^2.0.6", "immutable": "^4.0.0-rc.14", "react": "^17.0.2", "react-bootstrap": "^1.5.2", diff --git a/public/markers/blue.png b/public/markers/blue.png new file mode 100644 index 0000000..69590b9 Binary files /dev/null and b/public/markers/blue.png differ diff --git a/public/markers/green.png b/public/markers/green.png new file mode 100644 index 0000000..0f79315 Binary files /dev/null and b/public/markers/green.png differ diff --git a/public/markers/lightblue.png b/public/markers/lightblue.png new file mode 100644 index 0000000..03586d7 Binary files /dev/null and b/public/markers/lightblue.png differ diff --git a/public/markers/orange.png b/public/markers/orange.png new file mode 100644 index 0000000..8bee9da Binary files /dev/null and b/public/markers/orange.png differ diff --git a/public/markers/pink.png b/public/markers/pink.png new file mode 100644 index 0000000..71a7cd6 Binary files /dev/null and b/public/markers/pink.png differ diff --git a/public/markers/purple.png b/public/markers/purple.png new file mode 100644 index 0000000..b578738 Binary files /dev/null and b/public/markers/purple.png differ diff --git a/public/markers/red.png b/public/markers/red.png new file mode 100644 index 0000000..e993751 Binary files /dev/null and b/public/markers/red.png differ diff --git a/public/markers/yellow.png b/public/markers/yellow.png new file mode 100644 index 0000000..a9d65ac Binary files /dev/null and b/public/markers/yellow.png differ diff --git a/src/components/UI/UIDatetimeInput.tsx b/src/components/UI/UIDatetimeInput.tsx index 5ac4cac..a5ac027 100644 --- a/src/components/UI/UIDatetimeInput.tsx +++ b/src/components/UI/UIDatetimeInput.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { CSSProperties, useCallback } from 'react'; const baseStyle = { marginTop: '0.5em', @@ -12,9 +12,11 @@ const baseStyle = { export default function UIDatetimeInput({ onChangedDate, disabled = false, + style = {}, }: { onChangedDate: (date: Date | null) => void; disabled?: boolean; + style?: CSSProperties; }) { const onChange = useCallback( (e) => { @@ -28,7 +30,7 @@ export default function UIDatetimeInput({ ); return ( void; + password?: boolean; + style?: CSSProperties; }) { const onChange = useCallback( (e) => onChangeText(e.target.value), @@ -24,10 +29,12 @@ export default function UITextInput({ ); return ( ); } diff --git a/src/components/useOptimalPathWithDriver.ts b/src/components/useOptimalPathWithDriver.ts new file mode 100644 index 0000000..cf6be5c --- /dev/null +++ b/src/components/useOptimalPathWithDriver.ts @@ -0,0 +1,31 @@ +import { useDebugValue, useMemo } from 'react'; +import estimateOptimalPath, { Location } from '../lib/estimateoptimalpath'; + +export default function useOptimalPathWithDriver< + M extends Location, + D extends Location +>(driver: M, members: M[], destination: D) { + const path = useMemo(() => { + if (members.length === 0) { + return null; + } + + // O(n) + const passengerLocations = members.filter( + (location) => location !== driver + ); + + // O(n) + const path = estimateOptimalPath({ + from: driver, + waypoints: passengerLocations, + to: destination, + }); + + return path; + }, [destination, driver, members]); + + useDebugValue(path); + + return path; +} diff --git a/src/index.css b/src/index.css index 1229018..1bd9c8f 100644 --- a/src/index.css +++ b/src/index.css @@ -1,13 +1,14 @@ body { - margin: 0; - font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen', - 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', - sans-serif; - -webkit-font-smoothing: antialiased; - -moz-osx-font-smoothing: grayscale; + margin: 0; + font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', + 'Oxygen', 'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue', + sans-serif; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; + padding: 1rem; } code { - font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', - monospace; -} \ No newline at end of file + font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New', + monospace; +} diff --git a/src/index.tsx b/src/index.tsx index 85f18a6..8c222fc 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,16 +1,19 @@ import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; -import App from './components/App'; +import App from './mvp/App'; +import WheelShareProvider from './mvp/WheelShareProvider'; import reportWebVitals from './reportWebVitals'; -import AuthenticationWrapper from './components/Authentication/AuthenticationWrapper'; import * as serviceWorkerRegistration from './serviceWorkerRegistration'; ReactDOM.render( - + {/* - + */} + + + , document.getElementById('root') ); diff --git a/src/lib/estimateoptimalpath.ts b/src/lib/estimateoptimalpath.ts index 50b8f5c..d339052 100644 --- a/src/lib/estimateoptimalpath.ts +++ b/src/lib/estimateoptimalpath.ts @@ -23,26 +23,34 @@ export default function estimateOptimalPath< const { from, to, waypoints } = path; let sequence = [from, to]; + console.log('Sequence:', sequence, '; distance:', getDistance(...sequence)); + // Calculates all possible paths from the start to the end of the given path // and returns the one with the minimum distance for (let waypoint of waypoints) { // Iterate over all possible insertion points for the waypoint let minDistance = Infinity; - let insertionPoint = 0; + let minI = 0; for (let i = 0; i < sequence.length - 1; i++) { - const [start, end] = sequence.slice(i, i + 2); - - const distance = getDistance(start, waypoint, end); + const temporarySequence = [ + ...sequence.slice(0, i + 1), + waypoint, + ...sequence.slice(i + 1), + ]; + const distance = getDistance(...temporarySequence); if (distance < minDistance) { minDistance = distance; - insertionPoint = i; + minI = i; } } - sequence = sequence - .slice(0, insertionPoint + 1) - .concat([waypoint]) - .concat(sequence.slice(insertionPoint + 1)); + sequence = [ + ...sequence.slice(0, minI + 1), + waypoint, + ...sequence.slice(minI + 1), + ]; + + console.log('Sequence:', sequence, '; distance:', getDistance(...sequence)); } const newWaypoints = sequence.slice(1, sequence.length - 1); diff --git a/src/mvp/App.tsx b/src/mvp/App.tsx new file mode 100644 index 0000000..a246334 --- /dev/null +++ b/src/mvp/App.tsx @@ -0,0 +1,33 @@ +import EventAuthenticator from './EventAuthenticator'; +import PlanEvent from './PlanEvent'; + +function Home() { + return ( +
+

+ WheelShare +

+ +
+ ); +} + +export default function App() { + if (window.location.pathname === '/') { + return ; + } else { + // const eventName = window.location.pathname.slice(1); + + return ; + } +} diff --git a/src/mvp/EventAuthenticator.tsx b/src/mvp/EventAuthenticator.tsx new file mode 100644 index 0000000..5aafa73 --- /dev/null +++ b/src/mvp/EventAuthenticator.tsx @@ -0,0 +1,75 @@ +import { useEffect } from 'react'; +import { useState } from 'react'; +import { useContext } from 'react'; +import EventPage from './EventPage'; +import SignIn from './SignIn'; +import SignUp from './SignUp'; +import WheelShareContext from './WheelShareContext'; + +const useAuthenticated = () => { + const { authenticated } = useContext(WheelShareContext); + + return authenticated; +}; + +const eventUrl = window.location.pathname.slice(1); + +export default function EventAuthenticator() { + const authenticated = useAuthenticated(); + const [name, setName] = useState(''); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function fetchName() { + const res = await fetch(`//localhost:5000/events/${eventUrl}/preview`); + if (res.status === 200) { + const json = await res.json(); + setName(json.data.event.name); + } else { + setName(''); + } + } + + fetchName().finally(() => setLoading(false)); + }, []); + + const hasEvent = !!useContext(WheelShareContext).event; + + if (!name && !loading) { + return ( +
+

Event not found

+
+ ); + } + + if (!authenticated) { + return ( +
+

{name}

+ + +
+ ); + } + + if (!hasEvent) { + return null; + } + + return ; +} diff --git a/src/mvp/EventPage.tsx b/src/mvp/EventPage.tsx new file mode 100644 index 0000000..6cfd3a9 --- /dev/null +++ b/src/mvp/EventPage.tsx @@ -0,0 +1,387 @@ +import { Map, Marker, Polyline } from 'google-maps-react'; +import { useEffect } from 'react'; +import { useCallback, useContext, useMemo, useState } from 'react'; +import UIButton from '../components/UI/UIButton'; +import UIPlacesAutocomplete from '../components/UI/UIPlacesAutocomplete'; +import UIPressable from '../components/UI/UIPressable'; +import getDistance from '../lib/getdistance'; +import { + distanceAddedByWaypoint, + estimateOptimalWaypointOrder, +} from './routeOptimization'; +import { Signup } from './types'; +import WheelShareContext from './WheelShareContext'; + +function ll(x: { latitude: number; longitude: number }) { + return { lat: x.latitude, lng: x.longitude }; +} + +// eslint-disable-next-line +const markerColors = [ + 'blue', + 'green', + 'lightblue', + 'orange', + 'pink', + 'purple', + 'red', + 'yellow', +]; + +export default function EventPage() { + const ctx = useContext(WheelShareContext); + const api = ctx.api; + const event = ctx.event!; + + const [map, setMap] = useState>(); + + const signupsWithoutCarpool = useMemo(() => { + const signups = event?.signups ?? []; + const users: Signup[] = []; + for (let signup of Object.values(signups)) { + if (signup.groupId === null) { + users.push(signup); + } + } + return users; + }, [event?.signups]); + + const mySignupId = event.me.signupId; + const mySignup = event.signups[mySignupId]; + const currentPlaceId = mySignup.placeId; + + const myLatLng = mySignup.latitude ? ll(mySignup) : undefined; + + const canDrive = event.me.driving; + + const myCarpoolExtraInfo = event.me.carpool; + const myCarpool = myCarpoolExtraInfo + ? event.carpools.find( + (carpool) => carpool.groupId === myCarpoolExtraInfo.groupId + )! + : null; + + const myCarpoolHasOtherMembers = myCarpool && myCarpool.signupIds.length > 1; + + const focusMany = useCallback( + (locations: google.maps.LatLngLiteral[]) => { + const bounds = new google.maps.LatLngBounds(); + for (let location of locations) { + bounds.extend(location); + } + map?.fitBounds(bounds); + }, + [map] + ); + + const focus = useCallback( + (signupId: string) => { + const highlightedSignup = event.signups[signupId]; + const highlightedSignupLocation = highlightedSignup.latitude + ? ll(highlightedSignup) + : undefined; + + if (highlightedSignupLocation) { + map?.setCenter(highlightedSignupLocation); + map?.setZoom(14); + } + }, + [event, map] + ); + + const [invitedSignupIds, setInvitedSignupIds] = useState< + Record + >({}); + + const invitedSignups = useMemo( + () => Object.keys(invitedSignupIds).map((id) => event.signups[id]), + [event.signups, invitedSignupIds] + ); + + const optimalInvitedSignupPath = useMemo(() => { + if (!mySignup || !mySignup.latitude) { + return null; + } + + const invitedSignupsWithLocation = invitedSignups.filter( + (signup) => signup.latitude + ); + + const path = estimateOptimalWaypointOrder({ + from: mySignup, + // @ts-ignore + waypoints: invitedSignupsWithLocation, + to: event, + }); + + return path; + }, [event, invitedSignups, mySignup]); + + useEffect(() => { + if (optimalInvitedSignupPath && event.latitude && mySignup.latitude) { + focusMany([ + ll(event), + ll(mySignup), + ...optimalInvitedSignupPath.path.waypoints.map(ll), + ]); + } + }, [event, focusMany, mySignup, optimalInvitedSignupPath]); + + return ( +
+

{event.name}

+ setMap(map)} + containerStyle={{ + width: '30rem', + height: '25rem', + position: 'relative', + borderRadius: '0.5rem', + overflow: 'hidden', + }} + google={window.google} + style={{ width: '100%', height: '100%' }} + centerAroundCurrentLocation + > + {invitedSignups.length > 0 && + mySignup.latitude && + optimalInvitedSignupPath && ( + + )} + + + {myLatLng && ( + + )} + {Object.entries(event.signups).map(([id, signup]) => { + if (id === mySignupId) { + return null; + } + if (signup && signup.latitude) { + return ( + focus(id)} + icon={ + id in invitedSignupIds + ? '/markers/lightblue.png' + : '/markers/yellow.png' + } + /> + ); + } + + return null; + })} + +
+
+
+ My location + { + api.joinEvent(placeId); + }} + style={{ + border: '2px solid ' + (currentPlaceId ? '#30ff30' : 'gray'), + marginRight: '0.5rem', + }} + /> +
+ {!myCarpoolHasOtherMembers && ( +
+ Can I drive? + { + if (canDrive) { + setInvitedSignupIds({}); + api.setDriving(false); + } else { + api.setDriving(true); + } + }} + style={{ border: '2px solid #30ff00' }} + > + {canDrive ? 'Yes' : 'No'} + +
+ )} +
+ {myCarpoolHasOtherMembers && ( + <> +

My Carpool

+
+ + Driver: + {myCarpool?.driverName} + + Members: + {myCarpoolExtraInfo!.members.length > 1 ? ( +
    + {myCarpoolExtraInfo!.members.map((member) => { + if (member.signupId === event.me.signupId) { + return null; + } + const signup = event.signups[member.signupId]; + const name = member.name; + return ( +
  • + {name} +
  • + ); + })} +
+ ) : ( + <>(no members) + )} +
+ + )} + {canDrive && ( + <> +

People who need a ride

+ + {invitedSignups.length === 1 + ? '1 person' + : `${invitedSignups.length} people`}{' '} + in temporary carpool. Estimated distance (linear):{' '} + {optimalInvitedSignupPath?.distance.toFixed(1)} miles + +
+ +
+ {signupsWithoutCarpool.map((signup, index) => { + // Don't show people who don't have a location + if (!signup.latitude) { + return null; + } + + const name = 'Person ' + (index + 1); + const distanceAdded = (() => { + if (signup.id in invitedSignupIds) { + return null; + } + if (optimalInvitedSignupPath) { + return distanceAddedByWaypoint( + optimalInvitedSignupPath.path, + signup + ); + } + if (signup.latitude && mySignup.latitude) { + let distanceWithThem = getDistance(mySignup, signup, event); + let distanceWithoutThem = getDistance(mySignup, event); + return distanceWithThem - distanceWithoutThem; + } + return null; + })(); + const invited = signup.id in invitedSignupIds; + return ( +
+ {name} has no carpool.{' '} + {distanceAdded !== null && ( + <>+{distanceAdded.toFixed(1)} miles + )}{' '} +
+ focus(signup.id)} + style={{ marginRight: '0.5rem' }} + > + View on map + + {!invited ? ( + + setInvitedSignupIds((ids) => ({ + ...ids, + [signup.id]: true, + })) + } + > + Add + + ) : ( + { + setInvitedSignupIds((ids) => { + const newIds = { ...ids }; + delete newIds[signup.id]; + return newIds; + }); + }} + > + Remove + + )} +
+
+ ); + })} +
+ + )} +

Carpools

+
+ {event.carpools.map((carpool) => { + const isMe = carpool.groupId === mySignup.groupId; + const driverDisplayName = isMe + ? carpool.driverName + ' (my group)' + : carpool.driverName; + + const passengerCount = carpool.signupIds.length - 1; + + if (passengerCount === 0) { + return ( + + {driverDisplayName}: Available to drive + + ); + } + + return ( + + {driverDisplayName}: Driving +
+ {passengerCount} member + {passengerCount !== 1 ? 's' : ''} +
+ ); + })} +
+
+ ); +} diff --git a/src/mvp/PlanEvent.tsx b/src/mvp/PlanEvent.tsx new file mode 100644 index 0000000..2d2d4ca --- /dev/null +++ b/src/mvp/PlanEvent.tsx @@ -0,0 +1,70 @@ +import { useCallback } from 'react'; +import { useContext } from 'react'; +import { useState } from 'react'; +import UIButton from '../components/UI/UIButton'; +import UIDatetimeInput from '../components/UI/UIDatetimeInput'; +import UIPlacesAutocomplete from '../components/UI/UIPlacesAutocomplete'; +import UITextInput from '../components/UI/UITextInput'; +import WheelShareContext from './WheelShareContext'; + +export default function PlanEvent() { + const [name, setName] = useState(''); + const [startTime, setStartTime] = useState(null); + const [endTime, setEndTime] = useState(null); + const [placeId, setPlaceId] = useState(null); + const [moderatorCode, setModeratorCode] = useState(''); + + const { api } = useContext(WheelShareContext); + + const create = useCallback(() => { + if (!startTime || !endTime || !name || !moderatorCode || !placeId) { + console.error('Tried to create event with incomplete fields.'); + console.error({ startTime, endTime, name, moderatorCode, placeId }); + return; + } + + api + .createEvent(name, startTime, endTime, moderatorCode, placeId) + .then(({ eventId }) => { + console.log('resulting eventId:', eventId); + }); + }, [api, endTime, moderatorCode, name, placeId, startTime]); + + return ( +
+

Plan an event

+ Event Name + +
+ Location + setPlaceId(placeId)} + /> +
+ Start time + +
+ End time + +
+ Admin code (used to modify details about the event) + + Create +
+ ); +} diff --git a/src/mvp/SignIn.tsx b/src/mvp/SignIn.tsx new file mode 100644 index 0000000..4b64ea1 --- /dev/null +++ b/src/mvp/SignIn.tsx @@ -0,0 +1,46 @@ +import { useContext } from 'react'; +import { useState } from 'react'; +import UIButton from '../components/UI/UIButton'; +import UITextInput from '../components/UI/UITextInput'; +import WheelShareContext from './WheelShareContext'; + +export default function SignIn() { + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [error, setError] = useState(''); + + const { api } = useContext(WheelShareContext); + + return ( + <> +

Sign In

+ Email + +
+ Password + +
+ { + api.signin(email, password).catch((error) => { + setError('error'); + }); + }} + > + Sign In + +
+ {error === 'error' && 'Password was incorrect or signup was not found'} + + ); +} diff --git a/src/mvp/SignUp.tsx b/src/mvp/SignUp.tsx new file mode 100644 index 0000000..4834435 --- /dev/null +++ b/src/mvp/SignUp.tsx @@ -0,0 +1,53 @@ +import { useCallback, useContext, useState } from 'react'; +import UIButton from '../components/UI/UIButton'; +import UITextInput from '../components/UI/UITextInput'; +import WheelShareContext from './WheelShareContext'; + +export default function SignUp() { + const { api } = useContext(WheelShareContext); + + const [email, setEmail] = useState(''); + const [password, setPassword] = useState(''); + const [name, setName] = useState(''); + const [error, setError] = useState(''); + + const signup = useCallback(() => { + api.signup(email, password, name).catch((e) => { + console.error(e); + setError('There was an error signing up for the event.'); + }); + }, [api, email, name, password]); + + return ( + <> +

Sign Up

+ Name + +
+ Email + +
+ Password + +
+ + Sign Up + +
+ {error} + + ); +} diff --git a/src/mvp/WheelShareContext.ts b/src/mvp/WheelShareContext.ts new file mode 100644 index 0000000..23fb00a --- /dev/null +++ b/src/mvp/WheelShareContext.ts @@ -0,0 +1,34 @@ +import { createContext } from 'react'; +import { Event } from './types'; + +const WheelShareContext = createContext({ + event: null as Event | null, + authenticated: false, + api: { + /** Returns token */ + async signup(email: string, password: string, name: string): Promise { + throw new Error('Method not implemented.'); + }, + /** Returns token */ + async signin(email: string, password: string): Promise { + throw new Error('Method not implemented.'); + }, + async setDriving(driving: boolean): Promise { + throw new Error('Method not implemented.'); + }, + async joinEvent(placeId: string | null): Promise { + throw new Error('Method not implemented.'); + }, + async createEvent( + name: string, + startTime: Date, + endTime: Date, + moderatorCode: string, + placeId: string + ): Promise<{ eventId: string }> { + throw new Error('Method not implemented.'); + }, + }, +}); + +export default WheelShareContext; diff --git a/src/mvp/WheelShareProvider.tsx b/src/mvp/WheelShareProvider.tsx new file mode 100644 index 0000000..e0bd35f --- /dev/null +++ b/src/mvp/WheelShareProvider.tsx @@ -0,0 +1,202 @@ +import { useDebugValue } from 'react'; +import { useEffect } from 'react'; +import { ReactNode, useCallback, useMemo, useState } from 'react'; +import { Event } from './types'; +import WheelShareContext from './WheelShareContext'; + +const getToken = () => localStorage.getItem('token'); +const requestedEventUrl = window.location.pathname.slice(1); + +export default function WheelShareProvider({ + children, +}: { + children: ReactNode; +}) { + const [event, setEvent] = useState(null); + + const fetchEvent = useCallback(async function fetchEvent() { + const res = await fetch('//localhost:5000/events/' + requestedEventUrl, { + headers: { Authorization: 'Bearer ' + getToken() }, + }); + const json = await res.json(); + const { status, event } = json; + if (status !== 'success') { + console.error({ json }); + setEvent(null); + localStorage.removeItem('token'); + window.location.reload(); + } else { + setEvent(event); + } + }, []); + + useEffect(() => { + const token = getToken(); + + if (!token) { + return; + } + + fetchEvent(); + }, [fetchEvent]); + + const signup = useCallback( + async (email: string, password: string, name: string) => { + const result = await fetch( + `//localhost:5000/events/${requestedEventUrl}/signup`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + name, + email, + password, + }), + } + ); + + if (result.status !== 200) { + throw new Error(`Failed to signup: ${result.status}`); + } + + const { token } = await result.json(); + + localStorage.setItem('token', token); + + window.location.reload(); + }, + [] + ); + + const signin = useCallback(async (email: string, password: string) => { + const result = await fetch( + `//localhost:5000/events/${requestedEventUrl}/signin`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + email, + password, + }), + } + ); + + if (result.status !== 200) { + throw new Error(`Failed to signup: ${result.status}`); + } + + const { token } = await result.json(); + + localStorage.setItem('token', token); + + console.log(result); + + window.location.reload(); + }, []); + + const joinEvent = useCallback( + async (placeId: string) => { + const result = await fetch( + `//localhost:5000/events/${requestedEventUrl}/join`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getToken()}`, + }, + body: JSON.stringify({ + placeId, + }), + } + ); + if (result.status !== 200) { + throw new Error(`Failed to join event: ${result.status}`); + } + + await fetchEvent(); + }, + [fetchEvent] + ); + + const createEvent = useCallback( + async ( + name: string, + startTime: Date, + endTime: Date, + moderatorCode: string, + placeId: string + ) => { + const result = await fetch(`//localhost:5000/events`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + name, + startTime, + endTime, + moderatorCode, + placeId, + }), + }); + + if (result.status !== 200) { + throw new Error(`Failed to create event: ${result.status}`); + } + + const { eventId, token } = await result.json(); + + localStorage.setItem('token', token); + + return { eventId }; + }, + [] + ); + + const setDriving = useCallback( + async (driving: boolean) => { + const result = await fetch( + `//localhost:5000/events/${requestedEventUrl}/set_driving`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${getToken()}`, + }, + body: JSON.stringify({ + driving, + }), + } + ); + if (result.status !== 200) { + throw new Error(`Failed to join event: ${result.status}`); + } + + await fetchEvent(); + }, + [fetchEvent] + ); + + const value = useMemo( + () => ({ + api: { + signup, + signin, + joinEvent, + createEvent, + setDriving, + }, + event, + authenticated: !!getToken(), + }), + [createEvent, event, joinEvent, setDriving, signin, signup] + ); + + useDebugValue(value); + + return ( + + {children} + + ); +} diff --git a/src/mvp/routeOptimization.ts b/src/mvp/routeOptimization.ts new file mode 100644 index 0000000..fa0db3d --- /dev/null +++ b/src/mvp/routeOptimization.ts @@ -0,0 +1,86 @@ +import getDistance from '../lib/getdistance'; + +export type Location = { + latitude: number; + longitude: number; +}; + +export type Path = { + from: M; + waypoints: M[]; + to: D; +}; + +export function addWaypointOptimally( + path: Path, + waypoint: M +): { + path: Path; + distance: number; +} { + const { from, to, waypoints } = path; + let sequence = [from, ...waypoints, to]; + + // Iterate over all possible insertion points for the waypoint + let minDistance = Infinity; + let minI = 0; + for (let i = 0; i < sequence.length - 1; i++) { + const temporarySequence = [ + ...sequence.slice(0, i + 1), + waypoint, + ...sequence.slice(i + 1), + ]; + const distance = getDistance(...temporarySequence); + if (distance < minDistance) { + minDistance = distance; + minI = i; + } + } + + sequence = [ + ...sequence.slice(0, minI + 1), + waypoint, + ...sequence.slice(minI + 1), + ]; + + const newWaypoints = sequence.slice(1, sequence.length - 1); + + return { + path: { + from, + to, + waypoints: newWaypoints as M[], + }, + distance: getDistance(from, ...sequence, to), + }; +} + +export function estimateOptimalWaypointOrder< + M extends Location, + D extends Location +>(path: Path) { + let newPath: Path = { + from: path.from, + to: path.to, + waypoints: [], + }; + let distance = getDistance(path.from, path.to); + + for (let waypoint of path.waypoints) { + const result = addWaypointOptimally(newPath, waypoint); + newPath = result.path; + distance = result.distance; + } + + return { path: newPath, distance }; +} + +export function distanceAddedByWaypoint( + path: Path, + waypoint: M +): number { + const originalDistance = getDistance(path.from, ...path.waypoints, path.to); + const { distance: newDistance } = addWaypointOptimally(path, waypoint); + + return newDistance - originalDistance; +} diff --git a/src/mvp/types.ts b/src/mvp/types.ts new file mode 100644 index 0000000..8908b24 --- /dev/null +++ b/src/mvp/types.ts @@ -0,0 +1,37 @@ +export type Signup = { + id: string; + groupId: string | null; +} & ( + | { + placeId: string; + latitude: number; + longitude: number; + } + | { + placeId: null; + latitude: null; + longitude: null; + } +); + +export type Event = { + url: string; + name: string; + latitude: number; + longitude: number; + me: { + signupId: string; + name: string; + driving: boolean; + carpool: { + groupId: string; + members: { signupId: string; name: string; email: string }[]; + } | null; + }; + signups: Record; + carpools: { + groupId: string; + driverName: string; + signupIds: string[]; + }[]; +}; diff --git a/yarn.lock b/yarn.lock index 16e7a69..e103e7d 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2600,6 +2600,13 @@ "@types/minimatch" "*" "@types/node" "*" +"@types/google-maps-react@^2.0.5": + version "2.0.5" + resolved "https://registry.yarnpkg.com/@types/google-maps-react/-/google-maps-react-2.0.5.tgz#17cdf20cb6d8ae481f9b2fcfd93dcdaaa14cd069" + integrity sha512-qcsYlHiNH169Vf7jmkEwbzBDqBfqqzYkTgK1vL7qkWVZI04wFESADYVITuQunrZ9swY/SG+tTWUIXMlY4W8byw== + dependencies: + google-maps-react "*" + "@types/googlemaps@*": version "3.43.3" resolved "https://registry.npmjs.org/@types/googlemaps/-/googlemaps-3.43.3.tgz" @@ -6339,6 +6346,11 @@ globby@^6.1.0: pify "^2.0.0" pinkie-promise "^2.0.0" +google-maps-react@*, google-maps-react@^2.0.6: + version "2.0.6" + resolved "https://registry.yarnpkg.com/google-maps-react/-/google-maps-react-2.0.6.tgz#0473356207ab6b47227b393b89e4b83f6eab06da" + integrity sha512-M8Eo9WndfQEfxcmm6yRq03qdJgw1x6rQmJ9DN+a+xPQ3K7yNDGkVDbinrf4/8vcox7nELbeopbm4bpefKewWfQ== + graceful-fs@^4.1.11, graceful-fs@^4.1.15, graceful-fs@^4.1.2, graceful-fs@^4.1.6, graceful-fs@^4.2.0, graceful-fs@^4.2.4: version "4.2.6" resolved "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.6.tgz"