Skip to content

Commit

Permalink
feat: locations for custom events (#726)
Browse files Browse the repository at this point in the history
Co-authored-by: Yukai Gu <[email protected]>
  • Loading branch information
ap0nia and stevenguyukai authored Nov 24, 2023
1 parent f61a31c commit 6d32534
Show file tree
Hide file tree
Showing 12 changed files with 237 additions and 59 deletions.
14 changes: 13 additions & 1 deletion apps/antalmanac/src/components/Calendar/CourseCalendarEvent.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import locationIds from '$lib/location_ids';
import { useTabStore } from '$stores/TabStore';
import { formatTimes } from '$stores/calendarizeHelpers';
import { useTimeFormatStore } from '$stores/TimeStore';
import buildingCatalogue from '$lib/buildingCatalogue';

const styles: Styles<Theme, object> = {
courseContainer: {
Expand Down Expand Up @@ -141,6 +142,7 @@ export interface CourseEvent extends CommonCalendarEvent {
export interface CustomEvent extends CommonCalendarEvent {
customEventID: number;
isCustomEvent: true;
building: string;
days: string[];
}

Expand Down Expand Up @@ -288,10 +290,20 @@ const CourseCalendarEvent = (props: CourseCalendarEventProps) => {
</Paper>
);
} else {
const { title, customEventID } = courseInMoreInfo;
const { title, customEventID, building } = courseInMoreInfo;
return (
<Paper className={classes.customEventContainer} ref={paperRef}>
<div className={classes.title}>{title}</div>
<div className={classes.table}>
Location: &nbsp;&nbsp;
<Link
className={classes.clickableLocation}
to={`/map?location=${building ?? 0}`}
onClick={focusMap}
>
{building ? buildingCatalogue[+building].name : ''}
</Link>
</div>
<div className={classes.buttonBar}>
<div className={`${classes.colorPicker}`}>
<ColorPicker
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import React, { PureComponent } from 'react';
import {
Button,
Dialog,
Expand All @@ -12,35 +13,24 @@ import {
} from '@material-ui/core';
import { withStyles } from '@material-ui/core/styles';
import { Add, Edit } from '@material-ui/icons';
import React, { PureComponent } from 'react';
import type { RepeatingCustomEvent } from '@packages/antalmanac-types';

import DaySelector from './DaySelector';
import ScheduleSelector from './ScheduleSelector';
import { addCustomEvent, editCustomEvent } from '$actions/AppStoreActions';
import analyticsEnum, { logAnalytics } from '$lib/analytics';
import { isDarkMode } from '$lib/helpers';
import AppStore from '$stores/AppStore';
import { BuildingSelect, ExtendedBuilding } from '$components/inputs/building-select';

export { RepeatingCustomEvent };

const styles = {
textField: {
minWidth: 120,
},
};

/**
* There is another CustomEvent interface in CourseCalendarEvent and they are slightly different. This one encapsulates the occurences of an event on multiple days, like Monday Tuesday Wednesday all in the same object as specified by the days array. The other one, `CustomEventDialog`'s CustomEvent, represents only one day, like the event on Monday, and needs to be duplicated to be repeated across multiple days.
* https://github.com/icssc/AntAlmanac/wiki/The-Great-AntAlmanac-TypeScript-Rewritening%E2%84%A2#duplicate-interface-names-%EF%B8%8F
* TODO: This needs to be moved to course_data.types.ts. It's stupid that components need to import from here instead of $lib
*/
export interface RepeatingCustomEvent {
title: string;
start: string;
end: string;
days: boolean[];
customEventID: number;
color?: string;
}

interface CustomEventDialogProps {
customEvent?: RepeatingCustomEvent;
onDialogClose?: () => void;
Expand All @@ -58,6 +48,7 @@ const defaultCustomEvent: RepeatingCustomEvent = {
title: '',
days: [false, false, false, false, false, false, false],
customEventID: 0,
building: undefined,
};

class CustomEventDialog extends PureComponent<CustomEventDialogProps, CustomEventDialogState> {
Expand Down Expand Up @@ -108,16 +99,21 @@ class CustomEventDialog extends PureComponent<CustomEventDialogProps, CustomEven
this.setState({ days: days });
};

handleBuildingChange = (building?: ExtendedBuilding | null) => {
this.setState({ building: building?.id });
};

handleAddToCalendar = () => {
if (!this.state.days.some((day) => day) || this.state.scheduleIndices.length === 0) return;

const newCustomEvent = {
const newCustomEvent: RepeatingCustomEvent = {
color: this.props.customEvent ? this.props.customEvent.color : '#551a8b',
title: this.state.title,
days: this.state.days,
start: this.state.start,
end: this.state.end,
customEventID: this.props.customEvent ? this.props.customEvent.customEventID : Date.now(),
building: this.state.building,
};

if (this.props.customEvent) editCustomEvent(newCustomEvent, this.state.scheduleIndices);
Expand Down Expand Up @@ -197,6 +193,7 @@ class CustomEventDialog extends PureComponent<CustomEventDialogProps, CustomEven
/>
</form>
<DaySelector onSelectDay={this.handleDayChange} days={this.props.customEvent?.days} />
<BuildingSelect value={this.state.building} onChange={this.handleBuildingChange} />
<ScheduleSelector
scheduleIndices={this.state.scheduleIndices}
onSelectScheduleIndices={this.handleSelectScheduleIndices}
Expand Down
129 changes: 107 additions & 22 deletions apps/antalmanac/src/components/Map/Map.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,15 +5,16 @@ import { useSearchParams, useNavigate } from 'react-router-dom';
import L, { type Map, type LatLngTuple } from 'leaflet';
import { MapContainer, TileLayer } from 'react-leaflet';
import 'leaflet-routing-machine';
import { Autocomplete, Box, Paper, Tab, Tabs, TextField, Typography } from '@mui/material';
import { Box, Paper, Tab, Tabs, Typography } from '@mui/material';
import ClassRoutes from './Routes';
import LocationMarker from './Marker';
import UserLocator from './UserLocator';
import AppStore from '$stores/AppStore';
import locationIds from '$lib/location_ids';
import buildingCatalogue from '$lib/buildingCatalogue';
import type { Building } from '$lib/buildingCatalogue';
import buildingCatalogue, { Building } from '$lib/buildingCatalogue';
import type { CourseEvent } from '$components/Calendar/CourseCalendarEvent';
import { BuildingSelect, ExtendedBuilding } from '$components/inputs/building-select';
import { notNull } from '$lib/utils';
import { TILES_URL } from '$lib/api/endpoints';

const ATTRIBUTION_MARKUP =
Expand All @@ -38,7 +39,9 @@ interface MarkerContent {
export function getCoursesPerBuilding() {
const courseEvents = AppStore.getCourseEventsInCalendar();

const allBuildingCodes = courseEvents.flatMap((event) => event.locations.map((location) => location.building));
const courseBuildings = courseEvents.flatMap((event) => event.locations.map((location) => location.building));

const allBuildingCodes = [...courseBuildings];

const uniqueBuildingCodes = new Set(allBuildingCodes);

Expand Down Expand Up @@ -74,14 +77,66 @@ export function getCoursesPerBuilding() {
return coursesPerBuilding;
}

/**
* Get unique building names for the MUI Autocomplete.
* A building with a duplicate name will have a higher index then a `findIndex` for another building with the same name.
*/
const buildings = Object.entries(buildingCatalogue).filter(
([_, building], index, array) =>
array.findIndex(([_, otherBuilding]) => otherBuilding.name === building.name) === index
);
export function getCustomEventPerBuilding() {
const customEvents = AppStore.getCustomEvents();

const customEventBuildings = customEvents.map((e) => e.building).filter(notNull);

// convert all digit to name in customEventBuilding for example: 83096 -> ICS
for (let i = 0; i < customEventBuildings.length; i++) {
customEventBuildings[i] =
Object.keys(locationIds).find((key) => locationIds[key] === parseInt(customEventBuildings[i])) || '';
}

const allBuildingCodes = [...customEventBuildings];

const uniqueBuildingCodes = new Set(allBuildingCodes);

const validBuildingCodes = [...uniqueBuildingCodes].filter(
(buildingCode) => buildingCatalogue[locationIds[buildingCode]] != null
);

interface localCustomEventType {
title: string;
start: string;
end: string;
days: boolean[];
customEventID: number;
color?: string | undefined;
building?: string | undefined;
}

const customEventPerBuilding: Record<string, (localCustomEventType & Building & MarkerContent)[]> = {};
for (let i = 0; i < validBuildingCodes.length; i++) {
customEventPerBuilding[validBuildingCodes[i]] = customEvents
.filter((event) => {
return (
Object.keys(locationIds).find(
(key) => locationIds[key] === parseInt(event.building ? event.building : '')
) == validBuildingCodes[i]
);
})
.map((event) => {
const locationData = buildingCatalogue[locationIds[validBuildingCodes[i]]];
const key = `${event.title} @ ${event.building}`;
const acronym = locationData.name.substring(
locationData.name.indexOf('(') + 1,
locationData.name.indexOf(')')
);
const markerCustomEventData = {
key,
image: locationData.imageURLs[0],
acronym,
markerColor: event.color ? event.color : '',
location: locationData.name,
...locationData,
...event,
};
return markerCustomEventData;
});
}
return customEventPerBuilding;
}

/**
* Map of all course locations on UCI campus.
Expand All @@ -93,6 +148,7 @@ export default function CourseMap() {
const [searchParams] = useSearchParams();
const [selectedDayIndex, setSelectedDay] = useState(0);
const [markers, setMarkers] = useState(getCoursesPerBuilding());
const [customEventMarkers] = useState(getCustomEventPerBuilding());
const [calendarEvents, setCalendarEvents] = useState(AppStore.getCourseEventsInCalendar());

useEffect(() => {
Expand Down Expand Up @@ -146,9 +202,9 @@ export default function CourseMap() {
[setSelectedDay]
);

const handleSearch = useCallback(
(_event: React.SyntheticEvent, value: [string, Building] | null) => {
navigate(`/map?location=${value?.[0]}`);
const onBuildingChange = useCallback(
(building?: ExtendedBuilding | null) => {
navigate(`/map?location=${building?.id}`);
},
[navigate]
);
Expand Down Expand Up @@ -193,12 +249,27 @@ export default function CourseMap() {

const markersToday =
today === 'All' ? markerValues : markerValues.filter((course) => course.start.toString().includes(today));

return markersToday
.sort((a, b) => a.start.getTime() - b.start.getTime())
.filter((marker, i, arr) => arr.findIndex((other) => other.sectionCode === marker.sectionCode) === i);
}, [markers, today]);

const customEventMarkersToDisplay = useMemo(() => {
const markerValues = Object.keys(customEventMarkers).flatMap((markerKey) => customEventMarkers[markerKey]);

const markersToday =
today === 'All'
? markerValues
: markerValues.filter((event) => {
return event.days.some((day, index) => day && WORK_WEEK[index] === today);
});
return markersToday.sort((a, b) => {
const startDateA = new Date(`1970-01-01T${a.start}`);
const startDateB = new Date(`1970-01-01T${b.start}`);
return startDateA.getTime() - startDateB.getTime();
});
}, [customEventMarkers, today]);

/**
* Every two markers grouped as [start, destination] tuples for the routes.
*/
Expand All @@ -222,12 +293,7 @@ export default function CourseMap() {
<Tab key={day} label={day} sx={{ padding: 1, minHeight: 'auto', minWidth: '10%' }} />
))}
</Tabs>
<Autocomplete
options={buildings}
getOptionLabel={(option) => option[1].name ?? ''}
onChange={handleSearch}
renderInput={(params) => <TextField {...params} label="Search for a place" variant="filled" />}
/>
<BuildingSelect onChange={onBuildingChange} />
</Paper>

<TileLayer
Expand Down Expand Up @@ -291,6 +357,25 @@ export default function CourseMap() {
);
})}

{/* Draw a marker for each custom Event that occurs today. */}
{customEventMarkersToDisplay.map((customEventMarkers, index) => {
const customEventSameBuildingPrior = customEventMarkersToDisplay.slice(0, index);

return (
<Fragment key={Object.values(customEventMarkers).join('')}>
<LocationMarker
{...customEventMarkers}
label={'E'}
stackIndex={customEventSameBuildingPrior.length}
>
<Box>
<Typography variant="body2">Event: {customEventMarkers.title}</Typography>
</Box>
</LocationMarker>
</Fragment>
);
})}

{/* Render an additional marker if the user searched up a location. */}
{/* A unique key based on the building is used to make sure the previous marker un-renders. */}
{focusedLocation && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,25 @@ import { withStyles } from '@material-ui/core/styles';
import { ClassNameMap } from '@material-ui/core/styles/withStyles';
import { Delete } from '@material-ui/icons';
import moment from 'moment';
import { Link } from 'react-router-dom';
import { useCallback } from 'react';

import CustomEventDialog, { RepeatingCustomEvent } from '../../Calendar/Toolbar/CustomEventDialog/CustomEventDialog';
import ColorPicker from '../../ColorPicker';
import { deleteCustomEvent } from '$actions/AppStoreActions';
import analyticsEnum from '$lib/analytics';
import buildingCatalogue from '$lib/buildingCatalogue';
import { useTabStore } from '$stores/TabStore';

const styles = {
root: {
padding: '4px 4px 0px 8px',
},
customEventLocation: {
margin: '0.75rem',
color: '#bbbbbb',
fontSize: '1rem',
},
colorPicker: {
cursor: 'pointer',
'& > div': {
Expand Down Expand Up @@ -51,6 +60,12 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
return `${startTime.format('h:mm A')}${endTime.format('h:mm A')}${daysString}`;
};

const { setActiveTab } = useTabStore();

const focusMap = useCallback(() => {
setActiveTab(2);
}, [setActiveTab]);

return (
<Card>
<CardHeader
Expand All @@ -59,6 +74,15 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
title={customEvent.title}
subheader={readableDateAndTimeFormat(customEvent.start, customEvent.end, customEvent.days)}
/>
<div className={classes.customEventLocation}>
<Link
className={classes.clickableLocation}
to={`/map?location=${customEvent.building ?? 0}`}
onClick={focusMap}
>
{customEvent.building ? buildingCatalogue[+customEvent.building].name : ''}
</Link>
</div>
<CardActions disableSpacing={true}>
<div className={classes.colorPicker}>
<ColorPicker
Expand All @@ -68,14 +92,14 @@ const CustomEventDetailView = (props: CustomEventDetailViewProps) => {
analyticsCategory={analyticsEnum.addedClasses.title}
/>
</div>
<CustomEventDialog customEvent={customEvent} scheduleNames={props.scheduleNames} />
<IconButton
onClick={() => {
deleteCustomEvent(customEvent.customEventID);
}}
>
<Delete fontSize="small" />
</IconButton>
<CustomEventDialog customEvent={customEvent} scheduleNames={props.scheduleNames} />
</CardActions>
</Card>
);
Expand Down
Loading

0 comments on commit 6d32534

Please sign in to comment.