Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Split ExtendedData type into base data and additional data #546

Open
wants to merge 5 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
61 changes: 37 additions & 24 deletions src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,50 +1,54 @@
import React, { useEffect, useState } from 'react';
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { DateTime } from 'luxon';

import Navbar from './components/Navbar';
import ListPage from './pages/ListPage';
import MapPage from './pages/MapPage';
import NotFoundPage from './pages/NotFoundPage';
import { queryLocations, getLocationStatus } from './util/queryLocations';
import {
queryLocations,
getExtendedLocationData as getExtraLocationData,
} from './util/queryLocations';
import './App.css';
import {
IReadOnlyExtendedLocation,
IReadOnlyLocation,
IReadOnlyLocation_FromAPI_PostProcessed,
IReadOnlyLocationExtraDataMap,
} from './types/locationTypes';

const CMU_EATS_API_URL = 'https://dining.apis.scottylabs.org/locations';
// const CMU_EATS_API_URL = 'http://localhost:5173/example-response.json'; // for debugging purposes (note that you need an example-response.json file in the /public folder)
// const CMU_EATS_API_URL = 'http://localhost:5010/locations'; // for debugging purposes (note that you need an example-response.json file in the /public folder)
function assertThatLocationsAndExtraLocationDataAreInSync(
locations?: IReadOnlyLocation_FromAPI_PostProcessed[],
extraData?: IReadOnlyLocationExtraDataMap,
) {
locations?.forEach((location) => {
if (!extraData || !(location.conceptId in extraData)) {
console.error(location.conceptId, 'missing from extraData!');
}
});
}
function App() {
// Load locations
const [locations, setLocations] = useState<IReadOnlyLocation[]>();
const [extendedLocationData, setExtendedLocationData] =
useState<IReadOnlyExtendedLocation[]>();
const [locations, setLocations] =
useState<IReadOnlyLocation_FromAPI_PostProcessed[]>();
const [extraLocationData, setExtraLocationData] =
useState<IReadOnlyLocationExtraDataMap>();
useEffect(() => {
queryLocations(CMU_EATS_API_URL).then((parsedLocations) => {
setLocations(parsedLocations);
setExtraLocationData(getExtraLocationData(parsedLocations));
// set extended data in same render to keep the two things in sync
});
}, []);

// periodically update extra location data
useEffect(() => {
const intervalId = setInterval(
(function updateExtendedLocationData() {
if (locations !== undefined) {
// Remove .setZone('America/New_York') and change time in computer settings when testing
// Alternatively, simply set now = DateTime.local(2023, 12, 22, 18, 33); where the parameters are Y,M,D,H,M
const now = DateTime.now().setZone('America/New_York');
setExtendedLocationData(
locations.map((location) => ({
...location,
...getLocationStatus(location.times, now), // populate location with more detailed info relevant to current time
})),
);
}
return updateExtendedLocationData; // returns itself here
})(), // self-invoking function
1 * 1000, // updates every second
() => setExtraLocationData(getExtraLocationData(locations)),
1000,
);
setExtraLocationData(getExtraLocationData(locations));
return () => clearInterval(intervalId);
}, [locations]);

Expand All @@ -62,6 +66,11 @@ function App() {
return () => window.removeEventListener('online', handleOnline);
}, []);

assertThatLocationsAndExtraLocationDataAreInSync(
locations,
extraLocationData,
);

return (
<React.StrictMode>
<BrowserRouter>
Expand All @@ -82,14 +91,18 @@ function App() {
path="/"
element={
<ListPage
locations={extendedLocationData}
extraLocationData={extraLocationData}
locations={locations}
/>
}
/>
<Route
path="/map"
element={
<MapPage locations={extendedLocationData} />
<MapPage
locations={locations}
extraLocationData={extraLocationData}
/>
}
/>
<Route path="*" element={<NotFoundPage />} />
Expand Down
4 changes: 2 additions & 2 deletions src/components/EateryCard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore';

import TextProps from '../types/interfaces';
import {
IReadOnlyExtendedLocation,
IReadOnlyExtendedLocationData,
LocationState,
} from '../types/locationTypes';

Expand Down Expand Up @@ -164,7 +164,7 @@ const SpecialsContent = styled(Accordion)({
backgroundColor: '#23272A',
});

function EateryCard({ location }: { location: IReadOnlyExtendedLocation }) {
function EateryCard({ location }: { location: IReadOnlyExtendedLocationData }) {
const {
name,
location: locationText,
Expand Down
74 changes: 35 additions & 39 deletions src/pages/ListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,13 +1,14 @@
import { Typography, Grid, Alert, styled } from '@mui/material';
import { useEffect, useMemo, useState, useLayoutEffect } from 'react';
import Fuse from 'fuse.js';
import { useEffect, useMemo, useState } from 'react';
import Fuse, { IFuseOptions } from 'fuse.js';
import EateryCard from '../components/EateryCard';
import EateryCardSkeleton from '../components/EateryCardSkeleton';
import NoResultsError from '../components/NoResultsError';
import getGreeting from '../util/greeting';
import './ListPage.css';
import {
IReadOnlyExtendedLocation,
IReadOnlyLocationExtraDataMap,
IReadOnlyLocation_FromAPI_PostProcessed,
LocationState,
} from '../types/locationTypes';
import assert from '../util/assert';
Expand Down Expand Up @@ -65,52 +66,38 @@ function getPittsburghTime() {
return now.toLocaleString('en-US', options);
}

const FUSE_OPTIONS: IFuseOptions<IReadOnlyLocation_FromAPI_PostProcessed> = {
// keys to perform the search on
keys: ['name', 'location', 'shortDescription', 'description'],
ignoreLocation: true,
threshold: 0.2,
};

function ListPage({
extraLocationData,
locations,
}: {
locations: IReadOnlyExtendedLocation[] | undefined;
extraLocationData: IReadOnlyLocationExtraDataMap | undefined;
locations: IReadOnlyLocation_FromAPI_PostProcessed[] | undefined;
}) {
const greeting = useMemo(() => getGreeting(new Date().getHours()), []);
const fuse = useMemo(
() => new Fuse(locations ?? [], FUSE_OPTIONS),
[locations],
); // only update fuse when the raw data actually changes (we don't care about the status (like time until close) changing)

// Fuzzy search options
const fuseOptions = {
// keys to perform the search on
keys: ['name', 'location', 'shortDescription'],
threshold: 0.3,
ignoreLocation: true,
};

const [fuse, setFuse] = useState<Fuse<IReadOnlyExtendedLocation> | null>(
null,
);

// Search query processing
const [searchQuery, setSearchQuery] = useState('');
const processedSearchQuery = searchQuery.trim().toLowerCase();

const [filteredLocations, setFilteredLocations] = useState<
IReadOnlyExtendedLocation[]
>([]);

useEffect(() => {
if (locations) {
const fuseInstance = new Fuse(locations, fuseOptions);
setFuse(fuseInstance);
}
}, [locations]);

useLayoutEffect(() => {
if (locations === undefined || fuse === null) return;
const processedSearchQuery = searchQuery.trim().toLowerCase();

// Fuzzy search. If there's no search query, it returns all locations.
setFilteredLocations(
const filteredLocations = useMemo(
() =>
processedSearchQuery.length === 0
? locations
? (locations ?? [])
: fuse
.search(processedSearchQuery)
.map((result) => result.item),
);
}, [searchQuery, fuse, locations]);
[fuse, searchQuery],
);

// const [showAlert, setShowAlert] = useState(true);
const [showOfflineAlert, setShowOfflineAlert] = useState(!navigator.onLine);
Expand Down Expand Up @@ -162,7 +149,9 @@ function ListPage({
<div className="Container">
<header className="Locations-header">
<HeaderText variant="h3">
{locations === undefined ? 'Loading...' : greeting}
{extraLocationData === undefined
? 'Loading...'
: greeting}
</HeaderText>
<input
className="Locations-search"
Expand All @@ -173,7 +162,10 @@ function ListPage({
/>
</header>
{(() => {
if (locations === undefined) {
if (
locations === undefined ||
extraLocationData === undefined
) {
// Display skeleton cards while loading
return (
<Grid container spacing={2}>
Expand Down Expand Up @@ -204,6 +196,10 @@ function ListPage({
return (
<Grid container spacing={2}>
{[...filteredLocations]
.map((location) => ({
...location,
...extraLocationData[location.conceptId], // add on our extra data here
}))
.sort((location1, location2) => {
const state1 = location1.locationState;
const state2 = location2.locationState;
Expand Down
22 changes: 16 additions & 6 deletions src/pages/MapPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,10 @@ import {
import { CSSTransition } from 'react-transition-group';
import EateryCard from '../components/EateryCard';
import './MapPage.css';
import { IReadOnlyExtendedLocation } from '../types/locationTypes';
import {
IReadOnlyLocation_FromAPI_PostProcessed,
IReadOnlyLocationExtraDataMap,
} from '../types/locationTypes';

const token = process.env.VITE_MAPKITJS_TOKEN;

Expand All @@ -22,8 +25,10 @@ function abbreviate(longName: string) {

function MapPage({
locations,
extraLocationData,
}: {
locations: IReadOnlyExtendedLocation[] | undefined;
locations: IReadOnlyLocation_FromAPI_PostProcessed[] | undefined;
extraLocationData: IReadOnlyLocationExtraDataMap | undefined;
}) {
const [selectedLocationIndex, setSelectedLocationIndex] =
useState<number>();
Expand All @@ -49,8 +54,11 @@ function MapPage({
}),
[],
);
if (!locations) return undefined;

if (!locations || !extraLocationData) return undefined;
const extendedLocationData = locations.map((location) => ({
...location,
...extraLocationData[location.conceptId],
}));
return (
<div className="MapPage">
<Map
Expand All @@ -64,7 +72,7 @@ function MapPage({
showsUserLocationControl
allowWheelToZoom
>
{locations.map((location, locationIndex) => {
{extendedLocationData.map((location, locationIndex) => {
if (!location.coordinates) return undefined;
return (
<Marker
Expand Down Expand Up @@ -102,7 +110,9 @@ function MapPage({
<div className="MapDrawer" ref={drawerRef}>
{selectedLocationIndex !== undefined && (
<EateryCard
location={locations[selectedLocationIndex]}
location={
extendedLocationData[selectedLocationIndex]
}
/>
)}
</div>
Expand Down
53 changes: 28 additions & 25 deletions src/types/joiLocationTypes.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import Joi from 'joi';
import { isValidTimeSlotArray } from '../util/time';
import { IReadOnlyAPILocation } from './locationTypes';
import { IReadOnlyLocation_FromAPI_PreProcessed } from './locationTypes';
import assert from '../util/assert';

const { string, number, boolean } = Joi.types();
Expand All @@ -19,30 +19,33 @@ const ISpecialJoiSchema = Joi.object({
});

// Note: Keys without .required() are optional by default
export const ILocationAPIJoiSchema = Joi.object<IReadOnlyAPILocation>({
conceptId: number.required(),
name: string,
shortDescription: string,
description: string.required(),
url: string.required(),
menu: string,
location: string.required(),
coordinates: {
lat: number.required(),
lng: number.required(),
},
acceptsOnlineOrders: boolean.required(),
times: Joi.array()
.items(ITimeSlotJoiSchema)
.required()
.custom((val) => {
assert(isValidTimeSlotArray(val));
return val;
})
.message('Received invalid (probably improperly sorted) time slots!'),
todaysSpecials: Joi.array().items(ISpecialJoiSchema),
todaysSoups: Joi.array().items(ISpecialJoiSchema),
});
export const ILocationAPIJoiSchema =
Joi.object<IReadOnlyLocation_FromAPI_PreProcessed>({
conceptId: number.required(),
name: string,
shortDescription: string,
description: string.required(),
url: string.required(),
menu: string,
location: string.required(),
coordinates: {
lat: number.required(),
lng: number.required(),
},
acceptsOnlineOrders: boolean.required(),
times: Joi.array()
.items(ITimeSlotJoiSchema)
.required()
.custom((val) => {
assert(isValidTimeSlotArray(val));
return val;
})
.message(
'Received invalid (probably improperly sorted) time slots!',
),
todaysSpecials: Joi.array().items(ISpecialJoiSchema),
todaysSoups: Joi.array().items(ISpecialJoiSchema),
});
export const IAPIResponseJoiSchema = Joi.object<{ locations: any[] }>({
locations: Joi.array().required(),
}); // shallow validation to make sure we have the locations field. That's it.
Loading