map based mvp
1
.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/
|
||||
|
|
|
@ -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",
|
||||
|
|
BIN
public/markers/blue.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
public/markers/green.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
public/markers/lightblue.png
Normal file
After Width: | Height: | Size: 1.2 KiB |
BIN
public/markers/orange.png
Normal file
After Width: | Height: | Size: 3.3 KiB |
BIN
public/markers/pink.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
public/markers/purple.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
public/markers/red.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
BIN
public/markers/yellow.png
Normal file
After Width: | Height: | Size: 1.3 KiB |
|
@ -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 (
|
||||
<input
|
||||
style={baseStyle}
|
||||
style={{ ...baseStyle, ...style }}
|
||||
type="datetime-local"
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
|
|
|
@ -93,7 +93,7 @@ export default function UIPlacesAutocomplete({
|
|||
return (
|
||||
<div
|
||||
style={{
|
||||
width: '100%',
|
||||
// width: '100%',
|
||||
maxWidth: '100%',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
|
|
|
@ -1,3 +1,4 @@
|
|||
import { CSSProperties } from 'react';
|
||||
import { useCallback } from 'react';
|
||||
|
||||
const baseStyle = {
|
||||
|
@ -13,10 +14,14 @@ export default function UITextInput({
|
|||
value,
|
||||
disabled = false,
|
||||
onChangeText,
|
||||
password = false,
|
||||
style = {},
|
||||
}: {
|
||||
value: string;
|
||||
disabled?: boolean;
|
||||
onChangeText: (text: string) => void;
|
||||
password?: boolean;
|
||||
style?: CSSProperties;
|
||||
}) {
|
||||
const onChange = useCallback(
|
||||
(e) => onChangeText(e.target.value),
|
||||
|
@ -24,10 +29,12 @@ export default function UITextInput({
|
|||
);
|
||||
return (
|
||||
<input
|
||||
style={baseStyle}
|
||||
style={{ ...baseStyle, ...style }}
|
||||
value={value}
|
||||
disabled={disabled}
|
||||
onChange={onChange}
|
||||
type={password ? 'password' : 'text'}
|
||||
autoCapitalize="new-password"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
31
src/components/useOptimalPathWithDriver.ts
Normal file
|
@ -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<M, D>({
|
||||
from: driver,
|
||||
waypoints: passengerLocations,
|
||||
to: destination,
|
||||
});
|
||||
|
||||
return path;
|
||||
}, [destination, driver, members]);
|
||||
|
||||
useDebugValue(path);
|
||||
|
||||
return path;
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
|
||||
monospace;
|
||||
}
|
||||
|
|
|
@ -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(
|
||||
<React.StrictMode>
|
||||
<AuthenticationWrapper>
|
||||
{/* <AuthenticationWrapper>
|
||||
<App />
|
||||
</AuthenticationWrapper>
|
||||
</AuthenticationWrapper> */}
|
||||
<WheelShareProvider>
|
||||
<App />
|
||||
</WheelShareProvider>
|
||||
</React.StrictMode>,
|
||||
document.getElementById('root')
|
||||
);
|
||||
|
|
|
@ -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);
|
||||
|
|
33
src/mvp/App.tsx
Normal file
|
@ -0,0 +1,33 @@
|
|||
import EventAuthenticator from './EventAuthenticator';
|
||||
import PlanEvent from './PlanEvent';
|
||||
|
||||
function Home() {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'center',
|
||||
}}
|
||||
>
|
||||
<h1
|
||||
style={{
|
||||
fontSize: '2rem',
|
||||
}}
|
||||
>
|
||||
WheelShare
|
||||
</h1>
|
||||
<PlanEvent />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
if (window.location.pathname === '/') {
|
||||
return <Home />;
|
||||
} else {
|
||||
// const eventName = window.location.pathname.slice(1);
|
||||
|
||||
return <EventAuthenticator />;
|
||||
}
|
||||
}
|
75
src/mvp/EventAuthenticator.tsx
Normal file
|
@ -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 (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: 'min(100vw, 30rem)',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ textAlign: 'center' }}>Event not found</h1>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!authenticated) {
|
||||
return (
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
maxWidth: 'min(100vw, 30rem)',
|
||||
margin: '0 auto',
|
||||
}}
|
||||
>
|
||||
<h1 style={{ textAlign: 'center' }}>{name}</h1>
|
||||
<SignIn />
|
||||
<SignUp />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!hasEvent) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return <EventPage />;
|
||||
}
|
387
src/mvp/EventPage.tsx
Normal file
|
@ -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<google.maps.Map<Element>>();
|
||||
|
||||
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<string, boolean>
|
||||
>({});
|
||||
|
||||
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 (
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}
|
||||
>
|
||||
<h1>{event.name}</h1>
|
||||
<Map
|
||||
onReady={(_props, map) => 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 && (
|
||||
<Polyline
|
||||
path={[
|
||||
ll(mySignup),
|
||||
...optimalInvitedSignupPath.path.waypoints.map(ll),
|
||||
ll(event),
|
||||
]}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Marker key="event" position={ll(event)} icon="/markers/green.png" />
|
||||
{myLatLng && (
|
||||
<Marker key="me" position={myLatLng} icon="/markers/red.png" />
|
||||
)}
|
||||
{Object.entries(event.signups).map(([id, signup]) => {
|
||||
if (id === mySignupId) {
|
||||
return null;
|
||||
}
|
||||
if (signup && signup.latitude) {
|
||||
return (
|
||||
<Marker
|
||||
key={id}
|
||||
position={{ lat: signup.latitude, lng: signup.longitude }}
|
||||
onClick={() => focus(id)}
|
||||
icon={
|
||||
id in invitedSignupIds
|
||||
? '/markers/lightblue.png'
|
||||
: '/markers/yellow.png'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
})}
|
||||
</Map>
|
||||
<br />
|
||||
<div
|
||||
style={{ display: 'flex', flexDirection: 'row', alignItems: 'center' }}
|
||||
>
|
||||
<div>
|
||||
My location
|
||||
<UIPlacesAutocomplete
|
||||
placeId={currentPlaceId}
|
||||
onSelected={(address, placeId) => {
|
||||
api.joinEvent(placeId);
|
||||
}}
|
||||
style={{
|
||||
border: '2px solid ' + (currentPlaceId ? '#30ff30' : 'gray'),
|
||||
marginRight: '0.5rem',
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{!myCarpoolHasOtherMembers && (
|
||||
<div>
|
||||
Can I drive?
|
||||
<UIButton
|
||||
onClick={() => {
|
||||
if (canDrive) {
|
||||
setInvitedSignupIds({});
|
||||
api.setDriving(false);
|
||||
} else {
|
||||
api.setDriving(true);
|
||||
}
|
||||
}}
|
||||
style={{ border: '2px solid #30ff00' }}
|
||||
>
|
||||
{canDrive ? 'Yes' : 'No'}
|
||||
</UIButton>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{myCarpoolHasOtherMembers && (
|
||||
<>
|
||||
<h2>My Carpool</h2>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: '1px solid black',
|
||||
width: '30rem',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<span>
|
||||
<b>Driver: </b>
|
||||
{myCarpool?.driverName}
|
||||
</span>
|
||||
<b>Members: </b>
|
||||
{myCarpoolExtraInfo!.members.length > 1 ? (
|
||||
<ul>
|
||||
{myCarpoolExtraInfo!.members.map((member) => {
|
||||
if (member.signupId === event.me.signupId) {
|
||||
return null;
|
||||
}
|
||||
const signup = event.signups[member.signupId];
|
||||
const name = member.name;
|
||||
return (
|
||||
<li key={signup.id}>
|
||||
<b>{name}</b>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
) : (
|
||||
<>(no members)</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{canDrive && (
|
||||
<>
|
||||
<h2>People who need a ride</h2>
|
||||
<span>
|
||||
{invitedSignups.length === 1
|
||||
? '1 person'
|
||||
: `${invitedSignups.length} people`}{' '}
|
||||
in temporary carpool. Estimated distance (linear):{' '}
|
||||
{optimalInvitedSignupPath?.distance.toFixed(1)} miles
|
||||
</span>
|
||||
<br />
|
||||
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
alignItems: 'flex-start',
|
||||
width: '20rem',
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<div key={signup.id} style={{ marginBottom: '0.5rem' }}>
|
||||
<b>{name}</b> has no carpool.{' '}
|
||||
{distanceAdded !== null && (
|
||||
<>+{distanceAdded.toFixed(1)} miles</>
|
||||
)}{' '}
|
||||
<div style={{ display: 'flex' }}>
|
||||
<UIPressable
|
||||
onClick={() => focus(signup.id)}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
>
|
||||
View on map
|
||||
</UIPressable>
|
||||
{!invited ? (
|
||||
<UIPressable
|
||||
onClick={() =>
|
||||
setInvitedSignupIds((ids) => ({
|
||||
...ids,
|
||||
[signup.id]: true,
|
||||
}))
|
||||
}
|
||||
>
|
||||
Add
|
||||
</UIPressable>
|
||||
) : (
|
||||
<UIPressable
|
||||
onClick={() => {
|
||||
setInvitedSignupIds((ids) => {
|
||||
const newIds = { ...ids };
|
||||
delete newIds[signup.id];
|
||||
return newIds;
|
||||
});
|
||||
}}
|
||||
>
|
||||
Remove
|
||||
</UIPressable>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
<h2>Carpools</h2>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
border: '1px solid black',
|
||||
width: '30rem',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
{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 (
|
||||
<span key={carpool.groupId}>
|
||||
{driverDisplayName}: Available to drive
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<span key={carpool.groupId}>
|
||||
{driverDisplayName}: Driving
|
||||
<br />
|
||||
{passengerCount} member
|
||||
{passengerCount !== 1 ? 's' : ''}
|
||||
</span>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
70
src/mvp/PlanEvent.tsx
Normal file
|
@ -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 | Date>(null);
|
||||
const [endTime, setEndTime] = useState<null | Date>(null);
|
||||
const [placeId, setPlaceId] = useState<null | string>(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 (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', width: '30rem' }}>
|
||||
<h2>Plan an event</h2>
|
||||
<b>Event Name</b>
|
||||
<UITextInput
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
style={{ border: '1px solid gray' }}
|
||||
/>
|
||||
<br />
|
||||
<b>Location</b>
|
||||
<UIPlacesAutocomplete
|
||||
style={{ border: '1px solid gray' }}
|
||||
onSelected={(address, placeId) => setPlaceId(placeId)}
|
||||
/>
|
||||
<br />
|
||||
<b>Start time</b>
|
||||
<UIDatetimeInput
|
||||
onChangedDate={setStartTime}
|
||||
style={{ border: '1px solid gray' }}
|
||||
/>
|
||||
<br />
|
||||
<b>End time</b>
|
||||
<UIDatetimeInput
|
||||
onChangedDate={setEndTime}
|
||||
style={{ border: '1px solid gray' }}
|
||||
/>
|
||||
<br />
|
||||
<b>Admin code</b> (used to modify details about the event)
|
||||
<UITextInput
|
||||
value={moderatorCode}
|
||||
onChangeText={setModeratorCode}
|
||||
style={{ border: '1px solid gray' }}
|
||||
/>
|
||||
<UIButton onClick={create}>Create</UIButton>
|
||||
</div>
|
||||
);
|
||||
}
|
46
src/mvp/SignIn.tsx
Normal file
|
@ -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 (
|
||||
<>
|
||||
<h2>Sign In</h2>
|
||||
Email
|
||||
<UITextInput
|
||||
style={{ border: '1px solid #c0c0c0' }}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<br />
|
||||
Password
|
||||
<UITextInput
|
||||
style={{ border: '1px solid #c0c0c0' }}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
password
|
||||
/>
|
||||
<br />
|
||||
<UIButton
|
||||
style={{ border: '1px solid #c0c0c0' }}
|
||||
onClick={() => {
|
||||
api.signin(email, password).catch((error) => {
|
||||
setError('error');
|
||||
});
|
||||
}}
|
||||
>
|
||||
Sign In
|
||||
</UIButton>
|
||||
<br />
|
||||
{error === 'error' && 'Password was incorrect or signup was not found'}
|
||||
</>
|
||||
);
|
||||
}
|
53
src/mvp/SignUp.tsx
Normal file
|
@ -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 (
|
||||
<>
|
||||
<h2>Sign Up</h2>
|
||||
Name
|
||||
<UITextInput
|
||||
style={{ border: '1px solid #c0c0c0' }}
|
||||
value={name}
|
||||
onChangeText={setName}
|
||||
/>
|
||||
<br />
|
||||
Email
|
||||
<UITextInput
|
||||
style={{ border: '1px solid #c0c0c0' }}
|
||||
value={email}
|
||||
onChangeText={setEmail}
|
||||
/>
|
||||
<br />
|
||||
Password
|
||||
<UITextInput
|
||||
style={{ border: '1px solid #c0c0c0' }}
|
||||
value={password}
|
||||
onChangeText={setPassword}
|
||||
password
|
||||
/>
|
||||
<br />
|
||||
<UIButton style={{ border: '1px solid #c0c0c0' }} onClick={signup}>
|
||||
Sign Up
|
||||
</UIButton>
|
||||
<br />
|
||||
{error}
|
||||
</>
|
||||
);
|
||||
}
|
34
src/mvp/WheelShareContext.ts
Normal file
|
@ -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<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
},
|
||||
/** Returns token */
|
||||
async signin(email: string, password: string): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
},
|
||||
async setDriving(driving: boolean): Promise<void> {
|
||||
throw new Error('Method not implemented.');
|
||||
},
|
||||
async joinEvent(placeId: string | null): Promise<void> {
|
||||
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;
|
202
src/mvp/WheelShareProvider.tsx
Normal file
|
@ -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<Event | null>(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 (
|
||||
<WheelShareContext.Provider value={value}>
|
||||
{children}
|
||||
</WheelShareContext.Provider>
|
||||
);
|
||||
}
|
86
src/mvp/routeOptimization.ts
Normal file
|
@ -0,0 +1,86 @@
|
|||
import getDistance from '../lib/getdistance';
|
||||
|
||||
export type Location = {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
};
|
||||
|
||||
export type Path<M extends Location, D extends Location> = {
|
||||
from: M;
|
||||
waypoints: M[];
|
||||
to: D;
|
||||
};
|
||||
|
||||
export function addWaypointOptimally<M extends Location, D extends Location>(
|
||||
path: Path<M, D>,
|
||||
waypoint: M
|
||||
): {
|
||||
path: Path<M, D>;
|
||||
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<M, D>) {
|
||||
let newPath: Path<M, D> = {
|
||||
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<M extends Location, D extends Location>(
|
||||
path: Path<M, D>,
|
||||
waypoint: M
|
||||
): number {
|
||||
const originalDistance = getDistance(path.from, ...path.waypoints, path.to);
|
||||
const { distance: newDistance } = addWaypointOptimally(path, waypoint);
|
||||
|
||||
return newDistance - originalDistance;
|
||||
}
|
37
src/mvp/types.ts
Normal file
|
@ -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<string, Signup>;
|
||||
carpools: {
|
||||
groupId: string;
|
||||
driverName: string;
|
||||
signupIds: string[];
|
||||
}[];
|
||||
};
|
12
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"
|
||||
|
|