diff --git a/client/src/App.tsx b/client/src/App.tsx index a64c96bc6..2d351eaa5 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -34,13 +34,14 @@ import { ClassData, CourseCode, CourseData, + DisplayTimetablesMap, InInventory, SelectedClasses, - TimetableData, } from './interfaces/Periods'; import { setDropzoneRange, useDrag } from './utils/Drag'; import { downloadIcsFile } from './utils/generateICS'; import storage from './utils/storage'; +import { createDefaultTimetable } from './utils/timetableHelpers'; const StyledApp = styled(Box)` height: 100%; @@ -109,6 +110,7 @@ const App: React.FC = () => { firstDayOfTerm, setFirstDayOfTerm, setTermName, + setTermsData, setTermNumber, setCoursesList, setLastUpdated, @@ -163,13 +165,31 @@ const App: React.FC = () => { * Retrieves term data from the scraper backend */ const fetchTermData = async () => { - const termData = await getAvailableTermDetails(); - const { term, termName, termNumber, year, firstDayOfTerm } = termData; + const { term, termName, termNumber, year, firstDayOfTerm, termsData } = await getAvailableTermDetails(); setTerm(term); setTermName(termName); setTermNumber(termNumber); setYear(year); setFirstDayOfTerm(firstDayOfTerm); + setTermsData(termsData); + const oldData = storage.get('timetables'); + + // avoid overwriting data from previous save + const newTimetableTerms: DisplayTimetablesMap = { + ...{ + [termsData.prevTerm.term]: oldData.hasOwnProperty(termsData.prevTerm.term) + ? oldData[termsData.prevTerm.term] + : createDefaultTimetable(), + }, + ...{ + [termsData.newTerm.term]: oldData.hasOwnProperty(termsData.newTerm.term) + ? oldData[termsData.newTerm.term] + : createDefaultTimetable(), + }, + }; + + setDisplayTimetables(newTimetableTerms); + storage.set('timetables', newTimetableTerms); }; fetchReliably(fetchTermData); @@ -186,11 +206,11 @@ const App: React.FC = () => { }; if (year !== invalidYearFormat) fetchReliably(fetchCoursesList); - }, [year]); + }, [term, year]); // Fetching the saved timetables from local storage useEffect(() => { - const savedTimetables: TimetableData[] = storage.get('timetables'); + const savedTimetables: DisplayTimetablesMap = storage.get('timetables'); if (savedTimetables) { setDisplayTimetables(savedTimetables); } @@ -280,7 +300,7 @@ const App: React.FC = () => { const newSelectedCourses = [...selectedCourses]; const newCourseData = courseData; - // Update the existing courses with the new data (for changing timezones). + // Update the existing courses with the new data (for changing timezone). addedCourses.forEach((addedCourse) => { if (newSelectedCourses.find((x) => x.code === addedCourse.code)) { const index = newSelectedCourses.findIndex((x) => x.code === addedCourse.code); @@ -295,11 +315,10 @@ const App: React.FC = () => { newCourseData.map.push(addedCourse); } }); - setSelectedCourses(newSelectedCourses); setCourseData(newCourseData); - if (displayTimetables.length > 0) { + if (displayTimetables[term].length > 0) { setAssignedColors( useColorMapper( newSelectedCourses.map((course) => course.code), @@ -323,7 +342,7 @@ const App: React.FC = () => { setSelectedCourses(newSelectedCourses); const newCourseData = courseData; newCourseData.map = courseData.map.filter(() => { - for (const timetable of displayTimetables) { + for (const timetable of displayTimetables[term]) { for (const course of timetable.selectedCourses) { if (course.code.localeCompare(courseCode)) { return true; @@ -348,11 +367,20 @@ const App: React.FC = () => { * Populate selected courses, classes and created events with the data saved in local storage */ const updateTimetableEvents = () => { + if (!storage.get('timetables')[term]) { + // data stored in local storage not up to date with current term + const updatedWithTerms = { [term]: storage.get('timetables') }; + + storage.set('timetables', updatedWithTerms); + setDisplayTimetables(updatedWithTerms); + } + handleSelectCourse( - storage.get('timetables')[selectedTimetable].selectedCourses.map((course: CourseData) => course.code), + storage.get('timetables')[term][selectedTimetable].selectedCourses.map((course: CourseData) => course.code), true, (newSelectedCourses) => { - const timetableSelectedClasses: SelectedClasses = storage.get('timetables')[selectedTimetable].selectedClasses; + const timetableSelectedClasses: SelectedClasses = + storage.get('timetables')[term][selectedTimetable].selectedClasses; const savedClasses: SavedClasses = {}; @@ -391,8 +419,8 @@ const App: React.FC = () => { setSelectedClasses(newSelectedClasses); }, ); - setCreatedEvents(storage.get('timetables')[selectedTimetable].createdEvents); - setAssignedColors(storage.get('timetables')[selectedTimetable].assignedColors); + setCreatedEvents(storage.get('timetables')[term][selectedTimetable].createdEvents); + setAssignedColors(storage.get('timetables')[term][selectedTimetable].assignedColors); }; useEffect(() => { @@ -401,7 +429,7 @@ const App: React.FC = () => { // The following three useUpdateEffects update local storage whenever a change is made to the timetable useUpdateEffect(() => { - displayTimetables[selectedTimetable].selectedCourses = selectedCourses; + displayTimetables[term][selectedTimetable].selectedCourses = selectedCourses; const newCourseData = courseData; storage.set('courseData', newCourseData); storage.set('timetables', displayTimetables); @@ -409,19 +437,19 @@ const App: React.FC = () => { }, [selectedCourses]); useUpdateEffect(() => { - displayTimetables[selectedTimetable].selectedClasses = selectedClasses; + displayTimetables[term][selectedTimetable].selectedClasses = selectedClasses; storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); }, [selectedClasses]); useUpdateEffect(() => { - displayTimetables[selectedTimetable].createdEvents = createdEvents; + displayTimetables[term][selectedTimetable].createdEvents = createdEvents; storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); }, [createdEvents]); useUpdateEffect(() => { - displayTimetables[selectedTimetable].assignedColors = assignedColors; + displayTimetables[term][selectedTimetable].assignedColors = assignedColors; storage.set('timetables', displayTimetables); setDisplayTimetables(displayTimetables); }, [assignedColors]); diff --git a/client/src/api/getCourseInfo.ts b/client/src/api/getCourseInfo.ts index baf786bd5..3bbda1d92 100644 --- a/client/src/api/getCourseInfo.ts +++ b/client/src/api/getCourseInfo.ts @@ -85,16 +85,17 @@ const getCourseInfo = async ( isConvertToLocalTimezone: boolean, ): Promise => { const baseURL = `${API_URL.timetable}/terms/${year}-${term}`; + const COURSE_API_TIMEOUT = 2000; try { - const data = await timeoutPromise(1000, fetch(`${baseURL}/courses/${courseCode}/`)); + const data = await timeoutPromise(COURSE_API_TIMEOUT, fetch(`${baseURL}/courses/${courseCode}/`)); // Remove any leftover courses from localStorage if they are not offered in the current term // which is why a 400 error is returned if (data.status === 400) { - const selectedCourses = storage.get('timetables')[0].selectedCourses; + const selectedCourses = storage.get('timetables')[term][0].selectedCourses; if (selectedCourses.includes(courseCode)) { delete selectedCourses[courseCode]; - storage.set('timetables[0].selectedCourses', selectedCourses); + storage.set(`timetables[${term}][0].selectedCourses`, selectedCourses); } else { throw new NetworkError('Internal server error'); } diff --git a/client/src/components/EventShareModal.tsx b/client/src/components/EventShareModal.tsx index 14a1fa98c..46c861d09 100644 --- a/client/src/components/EventShareModal.tsx +++ b/client/src/components/EventShareModal.tsx @@ -1,8 +1,10 @@ -import { useState, useContext, useEffect } from 'react'; -import { useNavigate, useParams } from 'react-router-dom'; -import { Add, Close, LocationOn } from '@mui/icons-material'; +import { Add, Close } from '@mui/icons-material'; import { Card, CardProps, Dialog, IconButton } from '@mui/material'; import { styled } from '@mui/system'; +import isBase64 from 'is-base64'; +import { useContext, useEffect, useState } from 'react'; +import { useNavigate, useParams } from 'react-router-dom'; + import { AppContext } from '../context/AppContext'; import { CourseContext } from '../context/CourseContext'; import { StyledDialogTitle, StyledTitleContainer, StyledTopIcons } from '../styles/ControlStyles'; @@ -10,7 +12,6 @@ import { ExecuteButton, StyledListItemText, StyledLocationIcon } from '../styles import { StyledCardName } from '../styles/DroppedCardStyles'; import { createEventObj } from '../utils/createEvent'; import { resizeWeekArray } from '../utils/eventTimes'; -import isBase64 from 'is-base64'; const PreviewCard = styled(Card)` padding: 24px 16px; @@ -26,12 +27,8 @@ const PreviewCard = styled(Card)` color: white; `; -const StyledLocationOn = styled(LocationOn)` - font-size: 12px; -`; - const EventShareModal = () => { - let { encrypted } = useParams(); + const { encrypted } = useParams(); const navigate = useNavigate(); const [open, setOpen] = useState(true); diff --git a/client/src/components/controls/History.tsx b/client/src/components/controls/History.tsx index 2c878903b..eb967d52e 100644 --- a/client/src/components/controls/History.tsx +++ b/client/src/components/controls/History.tsx @@ -4,7 +4,7 @@ import React, { useContext, useEffect, useRef, useState } from 'react'; import { AppContext } from '../../context/AppContext'; import { CourseContext } from '../../context/CourseContext'; -import { CourseData, CreatedEvents, SelectedClasses, TimetableData } from '../../interfaces/Periods'; +import { CourseData, CreatedEvents, DisplayTimetablesMap, SelectedClasses } from '../../interfaces/Periods'; import { StyledDialogButtons, StyledDialogContent, StyledTitleContainer } from '../../styles/ControlStyles'; import { ActionsPointer, @@ -28,7 +28,7 @@ const History: React.FC = () => { const { selectedCourses, setSelectedCourses, selectedClasses, setSelectedClasses, createdEvents, setCreatedEvents } = useContext(CourseContext); - const { isDrag, setIsDrag, selectedTimetable, setSelectedTimetable, displayTimetables, setDisplayTimetables } = + const { isDrag, setIsDrag, selectedTimetable, setSelectedTimetable, displayTimetables, setDisplayTimetables, term } = useContext(AppContext); const timetableActions = useRef({}); @@ -41,8 +41,8 @@ const History: React.FC = () => { courses: CourseData[], classes: SelectedClasses, events: CreatedEvents, - timetableArg: TimetableData[] | ((prev: TimetableData[]) => void), - selected?: number + timetableArg: DisplayTimetablesMap, + selected?: number, ) => { setSelectedCourses(courses); setSelectedClasses(classes); @@ -59,7 +59,7 @@ const History: React.FC = () => { * @param direction Which way to update (1 for increment, -1 for decrement) */ const incrementActionsPointer = (direction: number) => { - const timetableId = displayTimetables[selectedTimetable].id; + const timetableId = displayTimetables[term][selectedTimetable].id; actionsPointer.current[timetableId] += direction; setDisableLeft(actionsPointer.current[timetableId] < 1); setDisableRight(actionsPointer.current[timetableId] + 1 >= timetableActions.current[timetableId].length); @@ -75,11 +75,16 @@ const History: React.FC = () => { return; // Prevents adding change induced by clicking redo/undo } - if (selectedTimetable >= displayTimetables.length) { + if (selectedTimetable >= displayTimetables[term]?.length) { return; } - const currentTimetable = displayTimetables[selectedTimetable]; + // waiting for timetable data to render + if (!(term in displayTimetables)) { + return; + } + + const currentTimetable = displayTimetables[term][selectedTimetable]; // Create object if it doesn't exist if (!timetableActions.current[currentTimetable.id]) { @@ -99,16 +104,16 @@ const History: React.FC = () => { } timetableActions.current[currentTimetable.id].push({ - name: displayTimetables[selectedTimetable].name, + name: displayTimetables[term][selectedTimetable].name, courses: [...selectedCourses], classes: duplicateClasses(selectedClasses), - events: { ...createdEvents } + events: { ...createdEvents }, }); incrementActionsPointer(1); }, [selectedClasses, isDrag, createdEvents, displayTimetables]); - //Disables reset timetable button when there is no courses, classes and events selected. + // Disables reset timetable button when there is no courses, classes and events selected. useEffect(() => { if (!isMounted.current) { isMounted.current = true; @@ -118,12 +123,12 @@ const History: React.FC = () => { const disableStatus = { current: true, all: true }; // More than one timetable is resetAll-able - if (displayTimetables.length > 1) { + if (displayTimetables[term].length > 1) { disableStatus.all = false; } // Current timetable being non-empty is resetAll and resetOne-able - const currentTimetable = displayTimetables[selectedTimetable]; + const currentTimetable = displayTimetables[term][selectedTimetable]; // if new timetable has been created then set reset to be true since no courses, classes or events selected if (actionsPointer.current[currentTimetable.id] < 1) { disableStatus.current = true; @@ -149,11 +154,12 @@ const History: React.FC = () => { }, [selectedTimetable, selectedCourses, selectedClasses, createdEvents, displayTimetables]); useEffect(() => { - if (displayTimetables.length < 1) { + // waiting for timetable data to render with valid term data + if (!(term in displayTimetables) || displayTimetables[term].length < 1) { return; } - const timetableId = displayTimetables[selectedTimetable].id; + const timetableId = displayTimetables[term][selectedTimetable].id; const currentPointer = actionsPointer.current[timetableId]; setDisableLeft(currentPointer === undefined || currentPointer < 1); setDisableRight(currentPointer === undefined || currentPointer + 1 >= timetableActions.current[timetableId].length); @@ -167,24 +173,37 @@ const History: React.FC = () => { incrementActionsPointer(direction); dontAdd.current = true; - const timetableId = displayTimetables[selectedTimetable].id; - const modifyTimetableName = (prev: TimetableData[]) => { - return prev.map((timetable) => { - return timetable.id === timetableId - ? { ...timetable, name: timetableActions.current[timetableId][actionsPointer.current[timetableId]].name } + const modifiedTimetableId = displayTimetables[term][selectedTimetable].id; + const modifiedTimetableName = { + ...displayTimetables, + [term]: displayTimetables[term].map((timetable) => { + return timetable.id === modifiedTimetableId + ? { + ...timetable, + name: timetableActions.current[modifiedTimetableId][actionsPointer.current[modifiedTimetableId]].name, + } : timetable; - }); + }), }; - const { courses, classes, events } = extractHistoryInfo(timetableId, timetableActions.current, actionsPointer.current); - setTimetableState(courses, classes, events, modifyTimetableName); + const { courses, classes, events } = extractHistoryInfo( + modifiedTimetableId, + timetableActions.current, + actionsPointer.current, + ); + + setTimetableState(courses, classes, events, modifiedTimetableName); }; /** * Resets all timetables - leave one as default */ const clearAll = () => { - setTimetableState([], {}, {}, createDefaultTimetable(), 0); + const newDisplayTimetables: DisplayTimetablesMap = { + ...displayTimetables, + [term]: createDefaultTimetable(), + }; + setTimetableState([], {}, {}, newDisplayTimetables, 0); }; /** @@ -195,7 +214,7 @@ const History: React.FC = () => { // event.metaKey corresponds to the Cmd key on Mac if (!(event.ctrlKey || event.metaKey) || !(event.key === 'z' || event.key === 'y' || event.key === 'd')) return; - const currentTimetable = displayTimetables[selectedTimetable]; + const currentTimetable = displayTimetables[term][selectedTimetable]; event.preventDefault(); diff --git a/client/src/components/navbar/Changelog.tsx b/client/src/components/navbar/Changelog.tsx index 46d234c98..98a48ec78 100644 --- a/client/src/components/navbar/Changelog.tsx +++ b/client/src/components/navbar/Changelog.tsx @@ -14,6 +14,10 @@ import React from 'react'; type Change = { date: string; changes: string[] }; const changelog: Change[] = [ + { + date: 'May 14, 2024', + changes: ['Users can access data across multiple terms'], + }, { date: 'February 16, 2024', changes: ['Footer redesign with icons and sponsor logos'], diff --git a/client/src/components/navbar/Navbar.tsx b/client/src/components/navbar/Navbar.tsx index 8d1187d21..8d37d2130 100644 --- a/client/src/components/navbar/Navbar.tsx +++ b/client/src/components/navbar/Navbar.tsx @@ -1,17 +1,16 @@ import { Description, Info, Security, Settings as SettingsIcon } from '@mui/icons-material'; -import { AppBar, Toolbar, Typography, useMediaQuery, useTheme } from '@mui/material'; +import { AppBar, Toolbar, Typography } from '@mui/material'; import { styled } from '@mui/system'; -import React, { useContext, useState } from 'react'; +import React, { useState } from 'react'; import notanglesLogoGif from '../../assets/notangles.gif'; import notanglesLogo from '../../assets/notangles_1.png'; -import { ThemeType } from '../../constants/theme'; -import { AppContext } from '../../context/AppContext'; import About from './About'; import Changelog from './Changelog'; import CustomModal from './CustomModal'; import Privacy from './Privacy'; import Settings from './Settings'; +import TermSelect from './TermSelect'; const LogoImg = styled('img')` height: 46px; @@ -37,22 +36,8 @@ const NavbarTitle = styled(Typography)` z-index: 1201; `; -const Weak = styled('span')` - font-weight: 300; - opacity: 0.8; - margin-left: 15px; - font-size: 90%; - vertical-align: middle; - position: relative; - bottom: 1px; - z-index: 1201; -`; - const Navbar: React.FC = () => { const [currLogo, setCurrLogo] = useState(notanglesLogo); - const { term, termName, year } = useContext(AppContext); - const theme = useTheme(); - const isMobile = useMediaQuery(theme.breakpoints.down('sm')); return ( @@ -64,10 +49,8 @@ const Navbar: React.FC = () => { onMouseOver={() => setCurrLogo(notanglesLogoGif)} onMouseOut={() => setCurrLogo(notanglesLogo)} /> - - Notangles - {isMobile ? term : termName.concat(', ', year)} - + Notangles + } diff --git a/client/src/components/navbar/TermSelect.tsx b/client/src/components/navbar/TermSelect.tsx new file mode 100644 index 000000000..f6d1ce97a --- /dev/null +++ b/client/src/components/navbar/TermSelect.tsx @@ -0,0 +1,102 @@ +import { FormControl, InputLabel, MenuItem, Select, useMediaQuery, useTheme } from '@mui/material'; +import { borderColor, styled } from '@mui/system'; +import React, { useContext } from 'react'; + +import { ThemeType } from '../../constants/theme'; +import { AppContext } from '../../context/AppContext'; +import { CourseContext } from '../../context/CourseContext'; + +const StyledInputLabel = styled(InputLabel)(() => ({ + '&.Mui-focused': { + color: 'white', + }, +})); + +const StyledSelect = styled(Select)(() => ({ + '& .MuiOutlinedInput-notchedOutline': { + borderColor: 'white', + }, + '.MuiSelect-icon': { + color: 'white', + }, + '&.Mui-focused .MuiOutlinedInput-notchedOutline': { + borderColor: 'white', + }, + '&:hover .MuiOutlinedInput-notchedOutline': { + borderColor: 'white', + }, +})); + +const TermSelect: React.FC = () => { + const { term, termName, setTermName, year, setTerm, setYear, setSelectedTimetable, displayTimetables, termsData } = + useContext(AppContext); + + const { setSelectedCourses, setSelectedClasses, setCreatedEvents, setAssignedColors } = useContext(CourseContext); + + const theme = useTheme(); + const isMobile = useMediaQuery(theme.breakpoints.down('sm')); + + let prevTermName = `Term ${termsData.prevTerm.term[1]}`; + if (prevTermName.includes('Summer')) { + prevTermName = 'Summer Term'; + } + + let newTermName = `Term ${termsData.newTerm.term[1]}`; + if (newTermName.includes('Summer')) { + newTermName = 'Summer Term'; + } + + const termData = new Set([ + ...(termsData.prevTerm.term.length > 0 ? [`${prevTermName}, ${termsData.prevTerm.year}`] : []), + `${newTermName}, ${termsData.newTerm.year}`, + ]); + + const selectTerm = (e: any) => { + const defaultStartTimetable = 0; + + const newTermName = e.target.value.split(', ')[0]; + let termNum = 'T' + newTermName.split(' ')[1]; + const newYear = e.target.value.split(', ')[1]; + + if (e.target.value.includes('Summer')) { + // This is a summer term. + termNum = 'Summer'; + } + + setTerm(termNum); + setYear(newYear); + setTermName(newTermName); + setSelectedTimetable(defaultStartTimetable); + setSelectedClasses(displayTimetables[termNum][defaultStartTimetable].selectedClasses); + setCreatedEvents(displayTimetables[termNum][defaultStartTimetable].createdEvents); + setSelectedCourses(displayTimetables[termNum][defaultStartTimetable].selectedCourses); + setAssignedColors(displayTimetables[termNum][defaultStartTimetable].assignedColors); + }; + + return ( + + + Select term + + + {Array.from(termData).map((term, index) => { + return ( + + {term} + + ); + })} + + + ); +}; + +export default TermSelect; diff --git a/client/src/components/timetableTabs/TimetableTabContextMenu.tsx b/client/src/components/timetableTabs/TimetableTabContextMenu.tsx index 5ffd71ecd..be4168d33 100644 --- a/client/src/components/timetableTabs/TimetableTabContextMenu.tsx +++ b/client/src/components/timetableTabs/TimetableTabContextMenu.tsx @@ -38,6 +38,7 @@ const TimetableTabContextMenu: React.FC = ({ ancho setSelectedTimetable, displayTimetables, setDisplayTimetables, + term, setAlertMsg, setAlertFunction, alertFunction, @@ -79,10 +80,10 @@ const TimetableTabContextMenu: React.FC = ({ ancho */ // Handler for deleting a timetable const handleDeleteTimetable = (targetIndex: number) => { - if (displayTimetables.length > 1) { + if (displayTimetables[term].length > 1) { prevTimetables = { selected: selectedTimetable, - timetables: displayTimetables.map((timetable: TimetableData) => { + timetables: displayTimetables[term].map((timetable: TimetableData) => { return { name: timetable.name, id: timetable.id, @@ -94,26 +95,35 @@ const TimetableTabContextMenu: React.FC = ({ ancho }), }; - const newIndex = targetIndex === displayTimetables.length - 1 ? targetIndex - 1 : targetIndex; - const newTimetables = displayTimetables.filter( - (timetable: TimetableData, index: number) => index !== targetIndex, - ); + const newIndex = targetIndex === displayTimetables[term].length - 1 ? targetIndex - 1 : targetIndex; + + const newDisplayTimetables = { + ...displayTimetables, + [term]: displayTimetables[term].filter((timetable: TimetableData, index: number) => index !== targetIndex), + }; + // Updating the timetables state to the new timetable index - setDisplayTimetables(newTimetables); + setDisplayTimetables(newDisplayTimetables); + // Destructure and rename (for clarity, do not shadow context variables) const { selectedCourses: newCourses, selectedClasses: newClasses, createdEvents: newEvents, assignedColors: newColors, - } = newTimetables[newIndex]; + } = newDisplayTimetables[term][newIndex]; setTimetableState(newCourses, newClasses, newEvents, newColors, newIndex); setOpenRestoreAlert(true); // If user chooses to undo the deletion then we will restore the previous state setAlertFunction(() => () => { - setDisplayTimetables(prevTimetables.timetables); + const restoredTimetables = { + ...displayTimetables, + [term]: prevTimetables.timetables, + }; + + setDisplayTimetables(restoredTimetables); const { selectedCourses, selectedClasses, createdEvents } = prevTimetables.timetables[prevTimetables.selected]; setTimetableState(selectedCourses, selectedClasses, createdEvents, assignedColors, prevTimetables.selected); return; @@ -132,7 +142,7 @@ const TimetableTabContextMenu: React.FC = ({ ancho }; const handleRenameOpen = () => { - const timetableName = displayTimetables[selectedTimetable].name; + const timetableName = displayTimetables[term][selectedTimetable].name; setRenamedString(timetableName); setRenamedHelper(`${timetableName.length}/30`); timetableName.length > 30 ? setRenamedErr(true) : setRenamedErr(false); @@ -149,11 +159,15 @@ const TimetableTabContextMenu: React.FC = ({ ancho if (renamedErr) return; - const newTimetables = [...displayTimetables]; - newTimetables[selectedTimetable].name = renamedString; + const newTimetables = { + ...displayTimetables, + [term]: displayTimetables[term].map((timetable, index) => { + return index === selectedTimetable ? { ...timetable, name: renamedString } : timetable; + }), + }; - storage.set('timetables', [...newTimetables]); - setDisplayTimetables([...newTimetables]); + storage.set('timetables', newTimetables); + setDisplayTimetables(newTimetables); setRenameOpen(false); }; @@ -168,11 +182,11 @@ const TimetableTabContextMenu: React.FC = ({ ancho // Handler to duplicate the selected timetable const handleDuplicateTimetable = () => { - if (displayTimetables.length >= TIMETABLE_LIMIT) { + if (displayTimetables[term].length >= TIMETABLE_LIMIT) { setAlertMsg('Maximum timetables reached'); setErrorVisibility(true); } else { - const currentTimetable = displayTimetables[selectedTimetable]; + const currentTimetable = displayTimetables[term][selectedTimetable]; const newTimetable = { name: currentTimetable.name + ' - Copy', @@ -184,13 +198,18 @@ const TimetableTabContextMenu: React.FC = ({ ancho }; const newTimetables = [ - ...displayTimetables.slice(0, selectedTimetable + 1), + ...displayTimetables[term].slice(0, selectedTimetable + 1), newTimetable, - ...displayTimetables.slice(selectedTimetable + 1), + ...displayTimetables[term].slice(selectedTimetable + 1), ]; - storage.set('timetables', newTimetables); - setDisplayTimetables(newTimetables); + const updatedTimetables = { + ...displayTimetables, + [term]: newTimetables, + }; + + storage.set('timetables', updatedTimetables); + setDisplayTimetables(updatedTimetables); const { selectedCourses, selectedClasses, createdEvents } = newTimetable; setTimetableState(selectedCourses, selectedClasses, createdEvents, assignedColors, selectedTimetable + 1); handleMenuClose(); diff --git a/client/src/components/timetableTabs/TimetableTabs.tsx b/client/src/components/timetableTabs/TimetableTabs.tsx index 024dc64b9..f56db7396 100644 --- a/client/src/components/timetableTabs/TimetableTabs.tsx +++ b/client/src/components/timetableTabs/TimetableTabs.tsx @@ -7,7 +7,13 @@ import { v4 as uuidv4 } from 'uuid'; import { darkTheme, lightTheme } from '../../constants/theme'; import { AppContext } from '../../context/AppContext'; import { CourseContext } from '../../context/CourseContext'; -import { CourseData, CreatedEvents, SelectedClasses, TimetableData } from '../../interfaces/Periods'; +import { + CourseData, + CreatedEvents, + DisplayTimetablesMap, + SelectedClasses, + TimetableData, +} from '../../interfaces/Periods'; import { createTimetableStyle, StyledIconButton, @@ -33,6 +39,7 @@ const TimetableTabs: React.FC = () => { setDisplayTimetables, setAlertMsg, setErrorVisibility, + term, } = useContext(AppContext); const { setSelectedCourses, setSelectedClasses, setCreatedEvents, setAssignedColors } = useContext(CourseContext); @@ -58,7 +65,7 @@ const TimetableTabs: React.FC = () => { selectedClasses: SelectedClasses, createdEvents: CreatedEvents, assignedColors: Record, - timetableIndex: number + timetableIndex: number, ) => { setSelectedCourses(selectedCourses); setSelectedClasses(selectedClasses); @@ -72,11 +79,11 @@ const TimetableTabs: React.FC = () => { */ // Creates new timetable const handleCreateTimetable = () => { - if (displayTimetables.length >= TIMETABLE_LIMIT) { + if (displayTimetables[term].length >= TIMETABLE_LIMIT) { setAlertMsg('Maximum timetables reached'); setErrorVisibility(true); } else { - const nextIndex = displayTimetables.length; + const nextIndex = displayTimetables[term].length; const newTimetable: TimetableData = { name: 'New Timetable', @@ -86,9 +93,14 @@ const TimetableTabs: React.FC = () => { createdEvents: {}, assignedColors: {}, }; - storage.set('timetables', [...displayTimetables, newTimetable]); - setDisplayTimetables([...displayTimetables, newTimetable]); + const addingNewTimetables: DisplayTimetablesMap = { + ...displayTimetables, + [term]: [...displayTimetables[term], newTimetable], + }; + storage.set('timetables', addingNewTimetables); + + setDisplayTimetables(addingNewTimetables); // Clearing the selected courses, classes and created events for the new timetable setTimetableState([], {}, {}, {}, nextIndex); @@ -116,11 +128,17 @@ const TimetableTabs: React.FC = () => { return; } - const newTimetables = [...displayTimetables]; + const newTimetables: TimetableData[] = [...displayTimetables[term]]; const draggedItem = newTimetables[source.index]; newTimetables.splice(source.index, 1); newTimetables.splice(destination.index, 0, draggedItem); - setDisplayTimetables(newTimetables); + + const rearrangedTimetables: DisplayTimetablesMap = { + ...displayTimetables, + [term]: newTimetables, + }; + + setDisplayTimetables(rearrangedTimetables); handleSwitchTimetables(newTimetables, destination.index); }; @@ -137,7 +155,7 @@ const TimetableTabs: React.FC = () => { // Right clicking a tab will switch to that tab and open the menu const handleRightTabClick = (event: React.MouseEvent, index: number) => { event.preventDefault(); - handleSwitchTimetables(displayTimetables, index); + handleSwitchTimetables(displayTimetables[term], index); // Anchoring the menu to the mouse position setAnchorElement({ x: event.clientX, y: event.clientY }); @@ -145,35 +163,43 @@ const TimetableTabs: React.FC = () => { return ( - + {(props) => ( - {displayTimetables.map((timetable: TimetableData, index: number) => ( - - {(props) => ( - handleSwitchTimetables(displayTimetables, index)} - onContextMenu={(e) => handleRightTabClick(e, index)} - > - {timetable.name} - {selectedTimetable === index ? ( - - - - ) : ( - <> - )} - - )} - - ))} + {Object.keys(displayTimetables).length > 0 + ? displayTimetables[term]?.map((timetable: TimetableData, index: number) => ( + + {(props) => { + if (props.draggableProps.style?.transform) { + const horizShift = props.draggableProps.style?.transform.match(/(-?\d+)/g)?.map(Number)![0]; + // forcing horizontal movement + props.draggableProps.style.transform = `translate(${horizShift ? horizShift : 0}px, 0)`; + } + return ( + handleSwitchTimetables(displayTimetables[term], index)} + onContextMenu={(e) => handleRightTabClick(e, index)} + ref={props.innerRef} + {...props.draggableProps} + {...props.dragHandleProps} + sx={TabStyle(index, selectedTimetable)} + > + {timetable.name} + {selectedTimetable === index ? ( + + + + ) : ( + <> + )} + + ); + }} + + )) + : null} {props.placeholder} )} diff --git a/client/src/constants/defaults.ts b/client/src/constants/defaults.ts index 0472ad111..4a1462648 100644 --- a/client/src/constants/defaults.ts +++ b/client/src/constants/defaults.ts @@ -10,7 +10,7 @@ const defaults: Record = { isHideExamClasses: false, isConvertToLocalTimezone: true, courseData: { map: [] }, - timetables: createDefaultTimetable(), + timetables: { 'T0': createDefaultTimetable() }, }; export default defaults; diff --git a/client/src/constants/timetable.ts b/client/src/constants/timetable.ts index f065a0868..8e17e1490 100644 --- a/client/src/constants/timetable.ts +++ b/client/src/constants/timetable.ts @@ -1,9 +1,21 @@ import { API_URL } from '../api/config'; import NetworkError from '../interfaces/NetworkError'; +import { TermDataMap } from '../interfaces/Periods'; import timeoutPromise from '../utils/timeoutPromise'; const REGULAR_TERM_STR_LEN = 2; +const parseYear = (termDate: string) => { + const regexp = /(\d{2})\/(\d{2})\/(\d{4})/; + + const matched = termDate.match(regexp); + let extractedYear = ''; + if (matched != null) { + extractedYear = matched[3]; + } + return extractedYear; +}; + /** * @returns The details of the latest term there is data for */ @@ -21,57 +33,84 @@ export const getAvailableTermDetails = async () => { if (localStorage.getItem('termData')) { termData = JSON.parse(localStorage.getItem('termData')!); } + let year = termData.year || '0000'; - let termNumber = Number(termData.termNumber) || 1; - let term = termData.termName || `T${termNumber}`; - let termName = `Term ${termNumber}`; + const termNumber = Number(termData.termNumber) || 1; let firstDayOfTerm = termData.firstDayOfTerm || `0000-00-00`; + const parseTermData = (termId: string) => { + let termNum; + let term = termData.termName || `T${termNumber}`; + let termName = `Term ${termNumber}`; + + if (termId.length === REGULAR_TERM_STR_LEN) { + // This is not a summer term. + termNum = parseInt(termId.substring(1)); + term = `T${termNum}`; + termName = `Term ${termNum}`; + } else { + // This is a summer term. + termName = `Summer Term`; + term = termId; + termNum = 0; + } + + return { term: term, termName: termName, termNum: termNum }; + }; + try { - const termDateFetch = await timeoutPromise(1000, fetch(`${API_URL.timetable}/startdate/notangles`)); + // get the latest/new term start date available from the scraper + const termDateFetch = await timeoutPromise(1000, fetch(`${API_URL.timetable}/availablestartdate`)); const termDateRes = await termDateFetch.text(); + const termIdFetch = await timeoutPromise(1000, fetch(`${API_URL.timetable}/availableterm`)); + const termIdRes = await termIdFetch.text(); - const regexp = /(\d{2})\/(\d{2})\/(\d{4})/; + // get the current/previous term date + const prevTermDate = await timeoutPromise(1000, fetch(`${API_URL.timetable}/currentstartdate`)); + const prevTermRes = await prevTermDate.text(); - const matched = termDateRes.match(regexp); - if (matched != null) { - year = matched[3]; + const prevTermId = await timeoutPromise(1000, fetch(`${API_URL.timetable}/currentterm`)); + const prevTermIdRes = await prevTermId.text(); + + const extractedCurrYear = parseYear(termDateRes); + if (extractedCurrYear.length > 0) { + year = extractedCurrYear; } + const prevYear = parseYear(prevTermRes); + const termDateSplit = termDateRes.split('/'); firstDayOfTerm = termDateSplit.reverse().join('-'); - const termIdRes = await termIdFetch.text(); - if (termIdRes.length === REGULAR_TERM_STR_LEN) { - // This is not a summer term. - termNumber = parseInt(termIdRes.substring(1)); - term = `T${termNumber}`; - termName = `Term ${termNumber}`; - } else { - // This is a summer term. - termName = `Summer Term`; - term = termIdRes; - termNumber = 0; // This is a summer term. - } + const newTerm = parseTermData(termIdRes); + const prevTerm = parseTermData(prevTermIdRes); + + const termsData: TermDataMap = { + prevTerm: { year: prevYear, term: prevTerm.term }, + newTerm: { year: year, term: newTerm.term }, + }; + // Store the term details in local storage. localStorage.setItem( 'termData', JSON.stringify({ year: year, - term: term, - termNumber: termNumber, - termName: termName, + term: newTerm.term, + termNumber: newTerm.termNum, + termName: newTerm.termName, firstDayOfTerm: firstDayOfTerm, + termsData: termsData, }), ); return { - term: term, - termName: termName, - termNumber: termNumber, year: year, + term: newTerm.term, + termNumber: newTerm.termNum, + termName: newTerm.termName, firstDayOfTerm: firstDayOfTerm, + termsData: termsData, }; } catch (e) { throw new NetworkError('Could not connect to timetable scraper!'); diff --git a/client/src/context/AppContext.tsx b/client/src/context/AppContext.tsx index a7e2bf089..c33fd8fbd 100644 --- a/client/src/context/AppContext.tsx +++ b/client/src/context/AppContext.tsx @@ -2,7 +2,7 @@ import { createContext, useState } from 'react'; import { getDefaultEndTime, getDefaultStartTime } from '../constants/timetable'; import { CoursesList } from '../interfaces/Courses'; -import { CourseDataMap, TimetableData } from '../interfaces/Periods'; +import { CourseDataMap, DisplayTimetablesMap, TermDataMap } from '../interfaces/Periods'; import { AppContextProviderProps } from '../interfaces/PropTypes'; import storage from '../utils/storage'; @@ -70,6 +70,9 @@ export interface IAppContext { termName: string; setTermName: (newTermName: string) => void; + termsData: TermDataMap; + setTermsData: (newTermData: TermDataMap) => void; + termNumber: number; setTermNumber: (newTermNumber: number) => void; @@ -85,7 +88,7 @@ export interface IAppContext { selectedTimetable: number; setSelectedTimetable: (newSelectedTimetable: number) => void; - displayTimetables: TimetableData[]; + displayTimetables: DisplayTimetablesMap; setDisplayTimetables: (newDisplayTimetable: any) => void; courseData: CourseDataMap; @@ -153,6 +156,9 @@ export const AppContext = createContext({ termName: `Term 0`, setTermName: () => {}, + termsData: { prevTerm: { year: '', term: '' }, newTerm: { year: '', term: '' } }, + setTermsData: () => {}, + termNumber: 0, setTermNumber: () => {}, @@ -168,7 +174,7 @@ export const AppContext = createContext({ selectedTimetable: 0, setSelectedTimetable: () => {}, - displayTimetables: [], + displayTimetables: {}, setDisplayTimetables: () => {}, courseData: { map: [] }, @@ -210,10 +216,16 @@ const AppContextProvider = ({ children }: AppContextProviderProps) => { const [term, setTerm] = useState(termData.term || `T0`); const [termName, setTermName] = useState(`Term ${termNumber}`); const [year, setYear] = useState(termData.year || '0000'); + const [termsData, setTermsData] = useState({ + prevTerm: { year: '', term: '' }, + newTerm: { year: year, term: term }, + }); const [firstDayOfTerm, setFirstDayOfTerm] = useState(termData.firstDayOfTerm || `0000-00-00`); const [coursesList, setCoursesList] = useState([]); const [selectedTimetable, setSelectedTimetable] = useState(0); - const [displayTimetables, setDisplayTimetables] = useState([]); + const [displayTimetables, setDisplayTimetables] = useState({ + [termData.term.length > 0 ? termData.term : '0']: [], + }); const [courseData, setCourseData] = useState({ map: [] }); const initialContext: IAppContext = { @@ -257,6 +269,8 @@ const AppContextProvider = ({ children }: AppContextProviderProps) => { setTerm, termName, setTermName, + termsData, + setTermsData, termNumber, setTermNumber, year, diff --git a/client/src/index.tsx b/client/src/index.tsx index a41ce9da1..d06bc6fbf 100644 --- a/client/src/index.tsx +++ b/client/src/index.tsx @@ -8,10 +8,10 @@ import { createRoot } from 'react-dom/client'; import { BrowserRouter, Route, Routes } from 'react-router-dom'; import App from './App'; +import EventShareModal from './components/EventShareModal'; import AppContextProvider from './context/AppContext'; import CourseContextProvider from './context/CourseContext'; import * as swRegistration from './serviceWorkerRegistration'; -import EventShareModal from './components/EventShareModal'; Sentry.init({ dsn: import.meta.env.VITE_APP_SENTRY_INGEST_CLIENT, diff --git a/client/src/interfaces/Periods.ts b/client/src/interfaces/Periods.ts index cf42a2a33..27d2c8d21 100644 --- a/client/src/interfaces/Periods.ts +++ b/client/src/interfaces/Periods.ts @@ -20,6 +20,11 @@ export interface CourseData { inventoryData: Record; } +export interface TermData { + year: string; + term: string; +} + export interface ClassData { id: string; courseCode: CourseCode; @@ -132,3 +137,12 @@ export interface Action { export interface CourseDataMap { map: CourseData[]; } + +export interface TermDataMap { + prevTerm: TermData; + newTerm: TermData; +} + +export interface DisplayTimetablesMap { + [key: string]: TimetableData[]; +}