From 0f34c6a3d4f27bb19b8712648e5045a7b8ae9702 Mon Sep 17 00:00:00 2001 From: topshenyi-web Date: Sat, 1 Feb 2025 20:03:31 -0500 Subject: [PATCH 1/5] fix: more semantic types, fuse only renders on initial api load, fix search, fix initial no search results glitch --- src/App.tsx | 61 ++++++++++++++++----------- src/components/EateryCard.tsx | 4 +- src/pages/ListPage.tsx | 79 ++++++++++++++++------------------- src/pages/MapPage.tsx | 22 +++++++--- src/types/joiLocationTypes.ts | 53 ++++++++++++----------- src/types/locationTypes.ts | 46 ++++++++++---------- src/util/queryLocations.ts | 30 ++++++++++--- 7 files changed, 167 insertions(+), 128 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 972bb2ce..6a3013ae 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -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_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_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(); - const [extendedLocationData, setExtendedLocationData] = - useState(); + const [locations, setLocations] = + useState(); + const [extraLocationData, setExtraLocationData] = + useState(); 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]); @@ -62,6 +66,11 @@ function App() { return () => window.removeEventListener('online', handleOnline); }, []); + assertThatLocationsAndExtraLocationDataAreInSync( + locations, + extraLocationData, + ); + return ( @@ -82,14 +91,18 @@ function App() { path="/" element={ } /> + } /> } /> diff --git a/src/components/EateryCard.tsx b/src/components/EateryCard.tsx index 5c570aec..9b881674 100644 --- a/src/components/EateryCard.tsx +++ b/src/components/EateryCard.tsx @@ -20,7 +20,7 @@ import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; import TextProps from '../types/interfaces'; import { - IReadOnlyExtendedLocation, + IReadOnlyExtendedLocationData, LocationState, } from '../types/locationTypes'; @@ -164,7 +164,7 @@ const SpecialsContent = styled(Accordion)({ backgroundColor: '#23272A', }); -function EateryCard({ location }: { location: IReadOnlyExtendedLocation }) { +function EateryCard({ location }: { location: IReadOnlyExtendedLocationData }) { const { name, location: locationText, diff --git a/src/pages/ListPage.tsx b/src/pages/ListPage.tsx index eff395c9..d01af186 100644 --- a/src/pages/ListPage.tsx +++ b/src/pages/ListPage.tsx @@ -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_PostProcessed, LocationState, } from '../types/locationTypes'; import assert from '../util/assert'; @@ -65,51 +66,34 @@ function getPittsburghTime() { return now.toLocaleString('en-US', options); } +const FUSE_OPTIONS: IFuseOptions = { + // keys to perform the search on + keys: ['name', 'location', 'shortDescription', 'description'], + ignoreLocation: true, + threshold: 0.3, +}; + function ListPage({ + extraLocationData, locations, }: { - locations: IReadOnlyExtendedLocation[] | undefined; + extraLocationData?: IReadOnlyLocationExtraDataMap; + locations?: IReadOnlyLocation_PostProcessed[]; }) { const greeting = useMemo(() => getGreeting(new Date().getHours()), []); + const fuse = useMemo(() => { + console.log('new fuse', locations); + return 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, - }; - - const [fuse, setFuse] = useState | null>( - null, - ); - - // Search query processing - const [searchQuery, setSearchQuery] = useState(''); - - const [filteredLocations, setFilteredLocations] = useState< - IReadOnlyExtendedLocation[] - >([]); + const [searchQuery, setSearchQuery] = useState('g'); + const processedSearchQuery = searchQuery.trim().toLowerCase(); - 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( - processedSearchQuery.length === 0 - ? locations - : fuse - .search(processedSearchQuery) - .map((result) => result.item), - ); - }, [searchQuery, fuse, locations]); + const filteredLocations = useMemo(() => { + return processedSearchQuery.length === 0 + ? (locations ?? []) + : fuse.search(processedSearchQuery).map((result) => result.item); + }, [fuse, searchQuery]); // const [showAlert, setShowAlert] = useState(true); const [showOfflineAlert, setShowOfflineAlert] = useState(!navigator.onLine); @@ -161,7 +145,9 @@ function ListPage({
- {locations === undefined ? 'Loading...' : greeting} + {extraLocationData === undefined + ? 'Loading...' + : greeting}
{(() => { - if (locations === undefined) { + if ( + locations === undefined || + extraLocationData === undefined + ) { // Display skeleton cards while loading return ( @@ -203,6 +192,10 @@ function ListPage({ return ( {[...filteredLocations] + .map((location) => ({ + ...location, + ...extraLocationData[location.conceptId], // add on our extra data here + })) .sort((location1, location2) => { const state1 = location1.locationState; const state2 = location2.locationState; diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index d8515d2a..a720bf12 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -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_PostProcessed, + IReadOnlyLocationExtraDataMap, +} from '../types/locationTypes'; const token = process.env.VITE_MAPKITJS_TOKEN; @@ -22,8 +25,10 @@ function abbreviate(longName: string) { function MapPage({ locations, + extraLocationData, }: { - locations: IReadOnlyExtendedLocation[] | undefined; + locations: IReadOnlyLocation_PostProcessed[] | undefined; + extraLocationData: IReadOnlyLocationExtraDataMap | undefined; }) { const [selectedLocationIndex, setSelectedLocationIndex] = useState(); @@ -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 (
- {locations.map((location, locationIndex) => { + {extendedLocationData.map((location, locationIndex) => { if (!location.coordinates) return undefined; return ( {selectedLocationIndex !== undefined && ( )}
diff --git a/src/types/joiLocationTypes.ts b/src/types/joiLocationTypes.ts index bc387130..6807ed41 100644 --- a/src/types/joiLocationTypes.ts +++ b/src/types/joiLocationTypes.ts @@ -1,6 +1,6 @@ import Joi from 'joi'; import { isValidTimeSlotArray } from '../util/time'; -import { IReadOnlyAPILocation } from './locationTypes'; +import { IReadOnlyAPILocation_PreProcessed } from './locationTypes'; import assert from '../util/assert'; const { string, number, boolean } = Joi.types(); @@ -19,30 +19,33 @@ const ISpecialJoiSchema = Joi.object({ }); // Note: Keys without .required() are optional by default -export const ILocationAPIJoiSchema = Joi.object({ - 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({ + 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. diff --git a/src/types/locationTypes.ts b/src/types/locationTypes.ts index e7ec84c4..945469e8 100644 --- a/src/types/locationTypes.ts +++ b/src/types/locationTypes.ts @@ -61,7 +61,7 @@ export enum LocationState { * (note: if you're updating this, you should * update the Joi Schema in joiLocationTypes.ts as well) */ -interface IAPILocation { +interface IAPILocation_PreProcessed { conceptId: number; name?: string; shortDescription?: string; @@ -79,46 +79,48 @@ interface IAPILocation { todaysSpecials?: ISpecial[]; todaysSoups?: ISpecial[]; } - -// All of the following are extended from the base API type - -// Base type -interface ILocation extends IAPILocation { +interface IAPILocation_PostProcessed extends IAPILocation_PreProcessed { name: string; // This field is now guaranteed to be defined } +// All of the following are extended from the base API type +// ILocationStatus* represents the data added on to IAPILocation // 'Closed' here refers to closed for the near future (no timeslots available) -interface ILocationStatusBase { +interface ILocationExtraData_Base { /** No forseeable opening times after *now* */ closedLongTerm: boolean; statusMsg: string; locationState: LocationState; } -interface ILocationStatusOpen extends ILocationStatusBase { +interface ILocationExtraDataNotPermanentlyClosed + extends ILocationExtraData_Base { + closedLongTerm: false; isOpen: boolean; timeUntil: number; - closedLongTerm: false; changesSoon: boolean; locationState: Exclude; } -interface ILocationStatusClosed extends ILocationStatusBase { +interface ILocationExtraDataPermanentlyClosed extends ILocationExtraData_Base { closedLongTerm: true; locationState: LocationState.CLOSED_LONG_TERM; } -interface IExtendedLocationOpen extends ILocation, ILocationStatusOpen {} -interface IExtendedLocationClosed extends ILocation, ILocationStatusClosed {} - -type ILocationStatus = ILocationStatusOpen | ILocationStatusClosed; -type IExtendedLocation = IExtendedLocationOpen | IExtendedLocationClosed; +type ILocationStatus = + | ILocationExtraDataNotPermanentlyClosed + | ILocationExtraDataPermanentlyClosed; /** What we get directly from the API (single location data) */ -export type IReadOnlyAPILocation = RecursiveReadonly; +export type IReadOnlyAPILocation_PreProcessed = + RecursiveReadonly; + +export type IReadOnlyLocation_PostProcessed = + RecursiveReadonly; -/** Base data for single location */ -export type IReadOnlyLocation = RecursiveReadonly; +export type IReadOnlyLocationExtraData = RecursiveReadonly; -/** Only extra status portion for location */ -export type IReadOnlyLocationStatus = RecursiveReadonly; +export type IReadOnlyLocationExtraDataMap = { + [conceptId: number]: IReadOnlyLocationExtraData; +}; -/** Combination of base ILocation type and ILocationStatus */ -export type IReadOnlyExtendedLocation = RecursiveReadonly; +/** once we combine extraDataMap with our base api data */ +export type IReadOnlyExtendedLocationData = IReadOnlyAPILocation_PreProcessed & + IReadOnlyLocationExtraData; diff --git a/src/util/queryLocations.ts b/src/util/queryLocations.ts index 82f5c757..41067ae4 100644 --- a/src/util/queryLocations.ts +++ b/src/util/queryLocations.ts @@ -5,10 +5,11 @@ import { DateTime } from 'luxon'; import { LocationState, ITimeSlotTime, - IReadOnlyLocation, - IReadOnlyLocationStatus, + IReadOnlyLocation_PostProcessed, + IReadOnlyLocationExtraData, ITimeSlots, - IReadOnlyAPILocation, + IReadOnlyAPILocation_PreProcessed, + IReadOnlyLocationExtraDataMap, } from '../types/locationTypes'; import { diffInMinutes, @@ -92,7 +93,7 @@ export function getStatusMessage( export function getLocationStatus( timeSlots: ITimeSlots, now: DateTime, -): IReadOnlyLocationStatus { +): IReadOnlyLocationExtraData { assert( isValidTimeSlotArray(timeSlots), `${JSON.stringify(timeSlots)} is invalid!`, @@ -129,7 +130,7 @@ export function getLocationStatus( } export async function queryLocations( cmuEatsAPIUrl: string, -): Promise { +): Promise { try { // Query locations const { data } = await axios.get(cmuEatsAPIUrl); @@ -152,7 +153,7 @@ export async function queryLocations( ); } return error === undefined; - }) as IReadOnlyAPILocation[]; + }) as IReadOnlyAPILocation_PreProcessed[]; return validLocations.map((location) => ({ ...location, @@ -163,3 +164,20 @@ export async function queryLocations( return []; } } + +export function getExtendedLocationData( + locations?: IReadOnlyLocation_PostProcessed[], +): IReadOnlyLocationExtraDataMap | 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'); + return locations?.reduce( + (acc, location) => ({ + ...acc, + [location.conceptId]: { + ...getLocationStatus(location.times, now), + }, + }), + {}, + ); // foldl! +} From 95dc93258da04c471dbc6fa5eb46dd37f10f388c Mon Sep 17 00:00:00 2001 From: topshenyi-web Date: Sat, 1 Feb 2025 20:22:55 -0500 Subject: [PATCH 2/5] chore: fix lint --- src/pages/ListPage.tsx | 26 +++++++++++++++----------- src/types/locationTypes.ts | 2 ++ 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/src/pages/ListPage.tsx b/src/pages/ListPage.tsx index d01af186..9a9e2145 100644 --- a/src/pages/ListPage.tsx +++ b/src/pages/ListPage.tsx @@ -77,23 +77,27 @@ function ListPage({ extraLocationData, locations, }: { - extraLocationData?: IReadOnlyLocationExtraDataMap; - locations?: IReadOnlyLocation_PostProcessed[]; + extraLocationData: IReadOnlyLocationExtraDataMap | undefined; + locations: IReadOnlyLocation_PostProcessed[] | undefined; }) { const greeting = useMemo(() => getGreeting(new Date().getHours()), []); - const fuse = useMemo(() => { - console.log('new fuse', locations); - return 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) + 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) const [searchQuery, setSearchQuery] = useState('g'); const processedSearchQuery = searchQuery.trim().toLowerCase(); - const filteredLocations = useMemo(() => { - return processedSearchQuery.length === 0 - ? (locations ?? []) - : fuse.search(processedSearchQuery).map((result) => result.item); - }, [fuse, searchQuery]); + const filteredLocations = useMemo( + () => + processedSearchQuery.length === 0 + ? (locations ?? []) + : fuse + .search(processedSearchQuery) + .map((result) => result.item), + [fuse, searchQuery], + ); // const [showAlert, setShowAlert] = useState(true); const [showOfflineAlert, setShowOfflineAlert] = useState(!navigator.onLine); diff --git a/src/types/locationTypes.ts b/src/types/locationTypes.ts index 945469e8..56332934 100644 --- a/src/types/locationTypes.ts +++ b/src/types/locationTypes.ts @@ -1,3 +1,5 @@ +/* eslint-disable @typescript-eslint/naming-convention */ + /** Note that everything being exported here is readonly */ export type RecursiveReadonly = T extends object From 6871c59b21d35be9701e2b3e86a5451d750de243 Mon Sep 17 00:00:00 2001 From: topshenyi-web Date: Sun, 2 Feb 2025 12:42:04 -0500 Subject: [PATCH 3/5] chore: cleanup --- src/App.tsx | 6 +++--- src/pages/ListPage.tsx | 6 +++--- src/pages/MapPage.tsx | 4 ++-- src/types/joiLocationTypes.ts | 4 ++-- src/types/locationTypes.ts | 18 ++++++++++-------- src/util/queryLocations.ts | 10 +++++----- 6 files changed, 25 insertions(+), 23 deletions(-) diff --git a/src/App.tsx b/src/App.tsx index 6a3013ae..f469aa12 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,7 +11,7 @@ import { } from './util/queryLocations'; import './App.css'; import { - IReadOnlyLocation_PostProcessed, + IReadOnlyLocation_FromAPI_PostProcessed, IReadOnlyLocationExtraDataMap, } from './types/locationTypes'; @@ -19,7 +19,7 @@ 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_PostProcessed[], + locations?: IReadOnlyLocation_FromAPI_PostProcessed[], extraData?: IReadOnlyLocationExtraDataMap, ) { locations?.forEach((location) => { @@ -31,7 +31,7 @@ function assertThatLocationsAndExtraLocationDataAreInSync( function App() { // Load locations const [locations, setLocations] = - useState(); + useState(); const [extraLocationData, setExtraLocationData] = useState(); useEffect(() => { diff --git a/src/pages/ListPage.tsx b/src/pages/ListPage.tsx index 9a9e2145..3387847e 100644 --- a/src/pages/ListPage.tsx +++ b/src/pages/ListPage.tsx @@ -8,7 +8,7 @@ import getGreeting from '../util/greeting'; import './ListPage.css'; import { IReadOnlyLocationExtraDataMap, - IReadOnlyLocation_PostProcessed, + IReadOnlyLocation_FromAPI_PostProcessed, LocationState, } from '../types/locationTypes'; import assert from '../util/assert'; @@ -66,7 +66,7 @@ function getPittsburghTime() { return now.toLocaleString('en-US', options); } -const FUSE_OPTIONS: IFuseOptions = { +const FUSE_OPTIONS: IFuseOptions = { // keys to perform the search on keys: ['name', 'location', 'shortDescription', 'description'], ignoreLocation: true, @@ -78,7 +78,7 @@ function ListPage({ locations, }: { extraLocationData: IReadOnlyLocationExtraDataMap | undefined; - locations: IReadOnlyLocation_PostProcessed[] | undefined; + locations: IReadOnlyLocation_FromAPI_PostProcessed[] | undefined; }) { const greeting = useMemo(() => getGreeting(new Date().getHours()), []); const fuse = useMemo( diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index a720bf12..12b98719 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -9,7 +9,7 @@ import { CSSTransition } from 'react-transition-group'; import EateryCard from '../components/EateryCard'; import './MapPage.css'; import { - IReadOnlyLocation_PostProcessed, + IReadOnlyLocation_FromAPI_PostProcessed, IReadOnlyLocationExtraDataMap, } from '../types/locationTypes'; @@ -27,7 +27,7 @@ function MapPage({ locations, extraLocationData, }: { - locations: IReadOnlyLocation_PostProcessed[] | undefined; + locations: IReadOnlyLocation_FromAPI_PostProcessed[] | undefined; extraLocationData: IReadOnlyLocationExtraDataMap | undefined; }) { const [selectedLocationIndex, setSelectedLocationIndex] = diff --git a/src/types/joiLocationTypes.ts b/src/types/joiLocationTypes.ts index 6807ed41..0af14f91 100644 --- a/src/types/joiLocationTypes.ts +++ b/src/types/joiLocationTypes.ts @@ -1,6 +1,6 @@ import Joi from 'joi'; import { isValidTimeSlotArray } from '../util/time'; -import { IReadOnlyAPILocation_PreProcessed } from './locationTypes'; +import { IReadOnlyLocation_FromAPI_PreProcessed } from './locationTypes'; import assert from '../util/assert'; const { string, number, boolean } = Joi.types(); @@ -20,7 +20,7 @@ const ISpecialJoiSchema = Joi.object({ // Note: Keys without .required() are optional by default export const ILocationAPIJoiSchema = - Joi.object({ + Joi.object({ conceptId: number.required(), name: string, shortDescription: string, diff --git a/src/types/locationTypes.ts b/src/types/locationTypes.ts index 56332934..b8b2b3aa 100644 --- a/src/types/locationTypes.ts +++ b/src/types/locationTypes.ts @@ -63,7 +63,7 @@ export enum LocationState { * (note: if you're updating this, you should * update the Joi Schema in joiLocationTypes.ts as well) */ -interface IAPILocation_PreProcessed { +interface ILocation_FromAPI_PreProcessed { conceptId: number; name?: string; shortDescription?: string; @@ -81,7 +81,8 @@ interface IAPILocation_PreProcessed { todaysSpecials?: ISpecial[]; todaysSoups?: ISpecial[]; } -interface IAPILocation_PostProcessed extends IAPILocation_PreProcessed { +interface ILocation_FromAPI_PostProcessed + extends ILocation_FromAPI_PreProcessed { name: string; // This field is now guaranteed to be defined } // All of the following are extended from the base API type @@ -111,18 +112,19 @@ type ILocationStatus = | ILocationExtraDataPermanentlyClosed; /** What we get directly from the API (single location data) */ -export type IReadOnlyAPILocation_PreProcessed = - RecursiveReadonly; +export type IReadOnlyLocation_FromAPI_PreProcessed = + RecursiveReadonly; -export type IReadOnlyLocation_PostProcessed = - RecursiveReadonly; +export type IReadOnlyLocation_FromAPI_PostProcessed = + RecursiveReadonly; export type IReadOnlyLocationExtraData = RecursiveReadonly; +/** what we'll typically pass into components for efficient look-up of extra data (like time until close) */ export type IReadOnlyLocationExtraDataMap = { [conceptId: number]: IReadOnlyLocationExtraData; }; /** once we combine extraDataMap with our base api data */ -export type IReadOnlyExtendedLocationData = IReadOnlyAPILocation_PreProcessed & - IReadOnlyLocationExtraData; +export type IReadOnlyExtendedLocationData = + IReadOnlyLocation_FromAPI_PreProcessed & IReadOnlyLocationExtraData; diff --git a/src/util/queryLocations.ts b/src/util/queryLocations.ts index 41067ae4..bf56c0bb 100644 --- a/src/util/queryLocations.ts +++ b/src/util/queryLocations.ts @@ -5,10 +5,10 @@ import { DateTime } from 'luxon'; import { LocationState, ITimeSlotTime, - IReadOnlyLocation_PostProcessed, + IReadOnlyLocation_FromAPI_PostProcessed, IReadOnlyLocationExtraData, ITimeSlots, - IReadOnlyAPILocation_PreProcessed, + IReadOnlyLocation_FromAPI_PreProcessed, IReadOnlyLocationExtraDataMap, } from '../types/locationTypes'; import { @@ -130,7 +130,7 @@ export function getLocationStatus( } export async function queryLocations( cmuEatsAPIUrl: string, -): Promise { +): Promise { try { // Query locations const { data } = await axios.get(cmuEatsAPIUrl); @@ -153,7 +153,7 @@ export async function queryLocations( ); } return error === undefined; - }) as IReadOnlyAPILocation_PreProcessed[]; + }) as IReadOnlyLocation_FromAPI_PreProcessed[]; return validLocations.map((location) => ({ ...location, @@ -166,7 +166,7 @@ export async function queryLocations( } export function getExtendedLocationData( - locations?: IReadOnlyLocation_PostProcessed[], + locations?: IReadOnlyLocation_FromAPI_PostProcessed[], ): IReadOnlyLocationExtraDataMap | 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 From 2983bf78e7725c58d7a3f097ab652dda05e39998 Mon Sep 17 00:00:00 2001 From: topshenyi-web Date: Sun, 2 Feb 2025 13:35:19 -0500 Subject: [PATCH 4/5] chore: tweak fuse options --- src/pages/ListPage.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/pages/ListPage.tsx b/src/pages/ListPage.tsx index 3387847e..c98d35c2 100644 --- a/src/pages/ListPage.tsx +++ b/src/pages/ListPage.tsx @@ -70,7 +70,7 @@ const FUSE_OPTIONS: IFuseOptions = { // keys to perform the search on keys: ['name', 'location', 'shortDescription', 'description'], ignoreLocation: true, - threshold: 0.3, + threshold: 0.2, }; function ListPage({ @@ -86,7 +86,7 @@ function ListPage({ [locations], ); // only update fuse when the raw data actually changes (we don't care about the status (like time until close) changing) - const [searchQuery, setSearchQuery] = useState('g'); + const [searchQuery, setSearchQuery] = useState(''); const processedSearchQuery = searchQuery.trim().toLowerCase(); const filteredLocations = useMemo( From 2788bed68c2a9a4281341916d59006d215c8b30b Mon Sep 17 00:00:00 2001 From: cirex Date: Sun, 9 Feb 2025 17:27:09 -0500 Subject: [PATCH 5/5] style: smoother loading experience (#549) * style: smoother loading experience * chore: style cleanup * chore: remove existing search param * chore: fix lint * fix: disable animation when user uses search * fix: a bit smoother title reveal * fix: lint * chore: I think this is a fair solution * fix: css variable delay refactor and card hover fix * chore: fix lint * chore: fix lint * style: add motion blur * fix: less blur --- .eslintrc | 70 ++++++++--------- src/App.css | 19 ++++- src/components/EateryCard.tsx | 47 ++++-------- src/components/EateryCardSkeleton.tsx | 34 +++++++-- src/pages/ListPage.css | 105 +++++++++++++++++++++++++- src/pages/ListPage.tsx | 37 +++++---- src/react.d.ts | 8 ++ 7 files changed, 231 insertions(+), 89 deletions(-) create mode 100644 src/react.d.ts diff --git a/.eslintrc b/.eslintrc index 9f7f70f2..812b3d8b 100644 --- a/.eslintrc +++ b/.eslintrc @@ -1,39 +1,35 @@ { - "extends": ["airbnb", - "airbnb-typescript", - "plugin:react/jsx-runtime", - "prettier", - "plugin:import/typescript"], - "env": { - "node": true, - "es6": true, - "browser": true - }, - "parserOptions": { - "ecmaVersion": "latest", - "sourceType": "module", - "ecmaFeatures": { - "jsx": true - }, - "project": ["./tsconfig.json"] - }, - "parser": "@typescript-eslint/parser", - "plugins": [ - "@typescript-eslint", - "react", - "prettier" - ], - "rules": { - "react/jsx-uses-react": "error", - "react/jsx-uses-vars": "error", - "react/prop-types": "off", - "no-console": [ - "error", - { "allow": ["error"] } - ], - "prettier/prettier": ["error"] - }, - "settings": { - "react": { "version": "detect" } - } + "extends": [ + "airbnb", + "airbnb-typescript", + "plugin:react/jsx-runtime", + "prettier", + "plugin:import/typescript" + ], + "env": { + "node": true, + "es6": true, + "browser": true + }, + "parserOptions": { + "ecmaVersion": "latest", + "sourceType": "module", + "ecmaFeatures": { + "jsx": true + }, + "project": ["./tsconfig.json"] + }, + "parser": "@typescript-eslint/parser", + "plugins": ["@typescript-eslint", "react", "prettier"], + "rules": { + "react/jsx-uses-react": "error", + "react/jsx-uses-vars": "error", + "react/prop-types": "off", + "react/require-default-props": "off", // we don't use prop-types for prop validation https://stackoverflow.com/a/64041197/13171687 + "no-console": ["error", { "allow": ["error"] }], + "prettier/prettier": ["error"] + }, + "settings": { + "react": { "version": "detect" } + } } diff --git a/src/App.css b/src/App.css index 4274be91..2bae9b9d 100644 --- a/src/App.css +++ b/src/App.css @@ -104,5 +104,22 @@ body { font-size: 1.5em; color: white; text-align: center; - background-color: #007fff; + background-image: linear-gradient( + 90deg, + hsl(210deg 94% 33%) 0%, + hsl(210deg 93% 34%) 8%, + hsl(210deg 92% 36%) 17%, + hsl(210deg 91% 38%) 25%, + hsl(210deg 90% 39%) 33%, + hsl(210deg 90% 41%) 42%, + hsl(210deg 89% 42%) 50%, + hsl(210deg 88% 44%) 58%, + hsl(210deg 88% 46%) 67%, + hsl(210deg 87% 47%) 75%, + hsl(210deg 87% 49%) 83%, + hsl(210deg 89% 51%) 92%, + hsl(210deg 94% 52%) 100% + ); + border-bottom: 2px solid hsl(210 94% 17%); + font-weight: 600; } diff --git a/src/components/EateryCard.tsx b/src/components/EateryCard.tsx index 9b881674..5e676720 100644 --- a/src/components/EateryCard.tsx +++ b/src/components/EateryCard.tsx @@ -14,7 +14,6 @@ import { CardActions, Avatar, Dialog, - keyframes, } from '@mui/material'; import ExpandMoreIcon from '@mui/icons-material/ExpandMore'; @@ -32,31 +31,6 @@ const colors: Record = { [LocationState.CLOSES_SOON]: '#f3f65d', }; -const glowAnimation = keyframes` - 0% { - box-shadow: 0 0 5px rgba(238, 111, 82, 0.7); - } - 50% { - box-shadow: 0 0 20px rgba(238, 111, 82, 0.7); - } - 100% { - box-shadow: 0 0 5px rgba(238, 111, 82, 0.7); - } -`; - -const StyledCard = styled(Card)({ - backgroundColor: '#23272A', - border: '2px solid rgba(0, 0, 0, 0.2)', - textAlign: 'left', - borderRadius: 7, - height: '100%', - justifyContent: 'flex-start', - transition: 'box-shadow 0.3s ease-in-out', - '&:hover': { - animation: `${glowAnimation} 1.5s infinite`, - }, -}); - const StyledCardHeader = styled(CardHeader)({ fontWeight: 500, backgroundColor: '#1D1F21', @@ -164,7 +138,15 @@ const SpecialsContent = styled(Accordion)({ backgroundColor: '#23272A', }); -function EateryCard({ location }: { location: IReadOnlyExtendedLocationData }) { +function EateryCard({ + location, + index = 0, + animate = false, +}: { + location: IReadOnlyExtendedLocationData; + index?: number; + animate?: boolean; +}) { const { name, location: locationText, @@ -183,7 +165,10 @@ function EateryCard({ location }: { location: IReadOnlyExtendedLocationData }) { return ( <> - +
)} - +
- +
))} - +
); diff --git a/src/components/EateryCardSkeleton.tsx b/src/components/EateryCardSkeleton.tsx index d3e4f311..b47d6e22 100644 --- a/src/components/EateryCardSkeleton.tsx +++ b/src/components/EateryCardSkeleton.tsx @@ -26,13 +26,22 @@ const SkeletonText = styled(Skeleton)({ marginBottom: '12px', }); -function EateryCardSkeleton() { +function EateryCardSkeleton({ index }: { index: number }) { return ( - + + } avatar={ } /> - - - + + + diff --git a/src/pages/ListPage.css b/src/pages/ListPage.css index 35892ac5..43d599e6 100644 --- a/src/pages/ListPage.css +++ b/src/pages/ListPage.css @@ -13,10 +13,47 @@ .Locations-header { display: grid; - grid-gap: 1rem; + grid-gap: 1rem; padding: 3rem 0; } +/* we need this to properly animate the mask gradient */ +@property --left-pos { + syntax: ''; + inherits: false; + initial-value: 0%; +} +.Locations-header__greeting { + --left-pos: 100%; + width: fit-content; + animation: slide-in 1.2s forwards; + animation-timing-function: cubic-bezier(0.04, 0.34, 0.5, 1.02); + /* we add a delay so it doesn't look jittery on page load */ + animation-delay: 0.1s; + opacity: 0; + mask-image: linear-gradient( + to right, + rgba(0, 0, 0, 1) 0%, + rgba(0, 0, 0, 1) var(--left-pos), + rgba(0, 0, 0, 0) calc(var(--left-pos) + 10%) + ); +} +@keyframes slide-in { + 0% { + opacity: 0; + /* clip-path: inset(0px 100% 0px 0px); */ + transform: translate(-10px, 0); + --left-pos: 0%; + } + 20% { + opacity: 1; + } + 100% { + opacity: 1; + transform: translate(0, 0); + --left-pos: 100%; + } +} @media screen and (min-width: 900px) { .Locations-header { grid-template-columns: 1fr 300px; @@ -56,3 +93,69 @@ border-color: #ee6f52; outline: none; } +.card { + background-color: #23272a; + border: 2px solid rgba(0, 0, 0, 0.2); + text-align: left; + border-radius: 7px; + justify-content: flex-start; + height: 100%; +} +.card--animated { + opacity: 0; + --card-show-delay: 0s; + --fade-in-animation: fade-in 0.7s forwards var(--card-show-delay) + cubic-bezier(0.08, 0.67, 0.64, 1.01); + animation: var(--fade-in-animation); + + &:hover { + animation: + var(--fade-in-animation), + glow-animation 1.5s ease 0s infinite; + } +} +@keyframes fade-in { + 0% { + opacity: 0; + transform: translate(-10px, 0); + filter: blur(3px); + } + 55% { + filter: blur(0); + } + + 100% { + transform: translate(0, 0); + opacity: 1; + filter: blur(0); + } +} +@keyframes glow-animation { + 0% { + box-shadow: 0 0 5px rgba(238, 111, 82, 0.7); + } + 50% { + box-shadow: 0 0 20px rgba(238, 111, 82, 0.7); + } + 100% { + box-shadow: 0 0 5px rgba(238, 111, 82, 0.7); + } +} +@keyframes oscillate-opacity { + 0% { + opacity: 1; + } + 30% { + opacity: 0.6; + } + 90% { + opacity: 1; + } +} +.skeleton-card--animated { + opacity: 0; + --oscillate-delay: 0s; + animation: + fade-in 1s cubic-bezier(0.08, 0.67, 0.64, 1.01) 1s forwards, + oscillate-opacity 2s ease-in-out var(--oscillate-delay) infinite; +} diff --git a/src/pages/ListPage.tsx b/src/pages/ListPage.tsx index c98d35c2..63a8678e 100644 --- a/src/pages/ListPage.tsx +++ b/src/pages/ListPage.tsx @@ -1,5 +1,5 @@ import { Typography, Grid, Alert, styled } from '@mui/material'; -import { useEffect, useMemo, useState } from 'react'; +import { useEffect, useLayoutEffect, useMemo, useState } from 'react'; import Fuse, { IFuseOptions } from 'fuse.js'; import EateryCard from '../components/EateryCard'; import EateryCardSkeleton from '../components/EateryCardSkeleton'; @@ -87,6 +87,7 @@ function ListPage({ ); // only update fuse when the raw data actually changes (we don't care about the status (like time until close) changing) const [searchQuery, setSearchQuery] = useState(''); + const [shouldAnimateCards, setShouldAnimateCards] = useState(true); const processedSearchQuery = searchQuery.trim().toLowerCase(); const filteredLocations = useMemo( @@ -103,7 +104,7 @@ function ListPage({ const [showOfflineAlert, setShowOfflineAlert] = useState(!navigator.onLine); // Load the search query from the URL, if any - useEffect(() => { + useLayoutEffect(() => { const urlQuery = new URLSearchParams(window.location.search).get( 'search', ); @@ -148,16 +149,22 @@ function ListPage({ )}
- - {extraLocationData === undefined - ? 'Loading...' - : greeting} - +
+ + {greeting} + +
setSearchQuery(e.target.value)} + onChange={(e) => { + setSearchQuery(e.target.value); + setShouldAnimateCards(false); + }} placeholder="Search" />
@@ -169,12 +176,14 @@ function ListPage({ // Display skeleton cards while loading return ( - {/* TODO: find a better solution */} {Array(36) .fill(null) - .map((_, index) => index) - .map((v) => ( - + .map((_, index) => ( + ))} ); @@ -229,10 +238,12 @@ function ListPage({ location2.timeUntil) ); }) - .map((location) => ( + .map((location, i) => ( ))} diff --git a/src/react.d.ts b/src/react.d.ts new file mode 100644 index 00000000..027b4ac2 --- /dev/null +++ b/src/react.d.ts @@ -0,0 +1,8 @@ +import 'react'; // eslint-disable-line react/no-typos +// (we need this import to properly extend the type) +declare module 'react' { + interface CSSProperties { + // allow css variable manipulation in style prop + [key: `--${string}`]: string | number; + } +}