diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 130658a..0d160ae 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -32,6 +32,7 @@ "passlink": "^1.1.0", "posthog-js": "^1.181.0", "react": "^18.2.0", + "react-big-calendar": "^1.15.0", "react-dom": "^18.2.0", "react-headless-pagination": "^0.1.0", "react-hot-toast": "^2.2.0", @@ -50,6 +51,7 @@ "@types/jest": "^27.0.1", "@types/node": "^16.9.1", "@types/react": "^17.0.21", + "@types/react-big-calendar": "^1.8.12", "@types/react-dom": "^17.0.9", "@types/react-redux": "^7.1.18", "autoprefixer": "^10.4.0", diff --git a/apps/frontend/src/app/constants.ts b/apps/frontend/src/app/constants.ts index 8ecf806..48241ae 100644 --- a/apps/frontend/src/app/constants.ts +++ b/apps/frontend/src/app/constants.ts @@ -135,4 +135,33 @@ export const GENED_SOURCES = { Dietrich: "https://www.cmu.edu/dietrich/gened/fall-2021-and-beyond/course-options/index.html", }; -export const STALE_TIME = 1000 * 60 * 60 * 24; // 1 day \ No newline at end of file +export const STALE_TIME = 1000 * 60 * 60 * 24; // 1 day + +export const CAL_VIEW = "cal"; +export const SCHED_VIEW = "sched"; + +export const CALENDAR_COLORS = [ + "#FFB3BA", // Red + "#FFDFBA", // Orange + "#FFFFBA", // Yellow + "#BAFFC9", // Green + "#BAE1FF", // Blue + "#D4BAFF", // Purple + "#FFC4E1", // Pink + "#C4E1FF", // Sky Blue + "#E1FFC4", // Lime + "#FFF4BA" // Cream +]; + +export const CALENDAR_COLORS_LIGHT = [ + "#FFE1E3", // Light Red + "#FFF2E3", // Light Orange + "#FFFFE3", // Light Yellow + "#E3FFE9", // Light Green + "#E3F3FF", // Light Blue + "#EEE3FF", // Light Purple + "#FFE7F3", // Light Pink + "#E7F3FF", // Light Sky Blue + "#F3FFE7", // Light Lime + "#FFFBE3" // Light Cream +]; \ No newline at end of file diff --git a/apps/frontend/src/app/types.ts b/apps/frontend/src/app/types.ts index 75096ec..ae24d43 100644 --- a/apps/frontend/src/app/types.ts +++ b/apps/frontend/src/app/types.ts @@ -1,4 +1,4 @@ -export type Semester = "fall" | "spring" | "summer"; +export type Semester = "fall" | "spring" | "summer" | ""; export type SummerSession = | "summer one" | "summer two" @@ -32,17 +32,19 @@ export interface Course { fces?: FCE[]; } +export interface Time { + days: number[]; + begin: string; + end: string; + building: string; + room: string; +} + interface Lesson { instructors: string[]; name: string; location: string; - times: { - days: number[]; - begin: string; - end: string; - building: string; - room: string; - }[]; + times: Time[]; } export type Lecture = Lesson; diff --git a/apps/frontend/src/app/user.ts b/apps/frontend/src/app/user.ts index 5d9d95b..ee440b4 100644 --- a/apps/frontend/src/app/user.ts +++ b/apps/frontend/src/app/user.ts @@ -1,6 +1,6 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { addToSet, removeFromSet } from "./utils"; -import { SEMESTERS_COUNTED } from "./constants"; +import {CAL_VIEW, SCHED_VIEW, SEMESTERS_COUNTED} from "./constants"; import { Semester } from "./types"; export interface UserState { @@ -24,6 +24,7 @@ export interface UserState { }; selectedSchool: string; selectedTags: string[]; + scheduleView: string; } const initialState: UserState = { @@ -47,6 +48,7 @@ const initialState: UserState = { }, selectedSchool: "SCS", selectedTags: [], + scheduleView: "cal", }; export const userSlice = createSlice({ @@ -102,7 +104,10 @@ export const userSlice = createSlice({ }, setSelectedTags: (state, action: PayloadAction) => { state.selectedTags = action.payload; - } + }, + toggleScheduleView: (state) => { + state.scheduleView = state.scheduleView === CAL_VIEW ? SCHED_VIEW : CAL_VIEW; + }, }, }); diff --git a/apps/frontend/src/app/userSchedules.ts b/apps/frontend/src/app/userSchedules.ts index 78449cc..34db77e 100644 --- a/apps/frontend/src/app/userSchedules.ts +++ b/apps/frontend/src/app/userSchedules.ts @@ -1,15 +1,30 @@ import { createSlice, PayloadAction } from "@reduxjs/toolkit"; -import { addToSet, removeFromSet } from "./utils"; +import { addToSet, getCalendarColor, removeFromSet, sessionToString } from "./utils"; import { Session } from "./types"; import { v4 as uuidv4 } from "uuid"; import { RootState } from "./store"; +export interface CourseSessions { + [courseID: string]: { + [sessionType: string]: string; + Color: string; + }; +} + +export interface HoverSession { + courseID: string; + [sessionType: string]: string; +} + export interface UserSchedule { name: string; courses: string[]; selected: string[]; id: string; - session?: Session; + session: Session; + courseSessions: CourseSessions; + numColors: number; + hoverSession?: HoverSession; } export interface UserSchedulesState { @@ -22,6 +37,24 @@ const initialState: UserSchedulesState = { saved: {}, }; +const getNewUserSchedule = (courseIDs: string[], id: string) : UserSchedule => { + return { + name: "My Schedule", + courses: courseIDs, + selected: courseIDs, + id: id, + session: { + year: "", + semester: "", + }, + courseSessions: courseIDs.reduce((acc: CourseSessions, courseID, i: number) => { + acc[courseID] = {Lecture: "", Section: "", Color: getCalendarColor(i)}; + return acc; + }, {}), + numColors: courseIDs.length, + }; +} + export const userSchedulesSlice = createSlice({ name: "userSchedules", initialState, @@ -32,12 +65,7 @@ export const userSchedulesSlice = createSlice({ addCourseToActiveSchedule: (state, action: PayloadAction) => { if (state.active === null) { const newId = uuidv4(); - state.saved[newId] = { - name: "My Schedule", - courses: [], - selected: [], - id: newId, - }; + state.saved[newId] = getNewUserSchedule([], newId); state.active = newId; } state.saved[state.active].courses = addToSet( @@ -48,6 +76,8 @@ export const userSchedulesSlice = createSlice({ state.saved[state.active].selected, action.payload ); + state.saved[state.active].courseSessions[action.payload] = {Lecture: "", Section: "", Color: getCalendarColor(state.saved[state.active].numColors)}; + state.saved[state.active].numColors += 1; }, removeCourseFromActiveSchedule: (state, action: PayloadAction) => { if (state.active === null) return; @@ -59,6 +89,8 @@ export const userSchedulesSlice = createSlice({ state.saved[state.active].selected, action.payload ); + + delete state.saved[state.active].courseSessions[action.payload]; }, selectCourseInActiveSchedule: (state, action: PayloadAction) => { if (state.active === null) return; @@ -88,22 +120,12 @@ export const userSchedulesSlice = createSlice({ }, createEmptySchedule: (state) => { const newId = uuidv4(); - state.saved[newId] = { - name: "My Schedule", - selected: [], - courses: [], - id: newId, - }; + state.saved[newId] = getNewUserSchedule([], newId); state.active = newId; }, createSharedSchedule: (state, action: PayloadAction) => { const newId = uuidv4(); - state.saved[newId] = { - name: "Shared Schedule", - selected: action.payload, - courses: action.payload, - id: newId, - }; + state.saved[newId] = getNewUserSchedule(action.payload, newId); state.active = newId; }, deleteSchedule: (state, action: PayloadAction) => { @@ -123,6 +145,26 @@ export const userSchedulesSlice = createSlice({ state.saved[state.active].name = action.payload; } }, + updateActiveScheduleSession: (state, action: PayloadAction) => { + if (state.active !== null) { + state.saved[state.active].session = action.payload; + } + }, + updateActiveScheduleCourseSession: (state, action: PayloadAction<{ courseID: string, sessionType: string, session: string }>) => { + if (state.active !== null) { + state.saved[state.active].courseSessions[action.payload.courseID][action.payload.sessionType] = action.payload.session + } + }, + setHoverSession: (state, action: PayloadAction<{ courseID: string, [sessionType: string]: string }>) => { + if (state.active !== null) { + state.saved[state.active].hoverSession = action.payload; + } + }, + clearHoverSession: (state) => { + if (state.active !== null) { + state.saved[state.active].hoverSession = undefined; + } + }, }, }); @@ -138,4 +180,27 @@ export const selectSelectedCoursesInActiveSchedule = ( return state.schedules.saved[state.schedules.active].selected; }; +export const selectSessionInActiveSchedule = ( + state: RootState +): string => { + if (state.schedules.active === null) return ""; + const session = state.schedules.saved[state.schedules.active].session; + if (session?.semester === "") return ""; + return sessionToString(session); +}; + +export const selectCourseSessionsInActiveSchedule = ( + state: RootState +): CourseSessions => { + if (state.schedules.active === null) return {}; + return state.schedules.saved[state.schedules.active].courseSessions; +}; + +export const selectHoverSessionInActiveSchedule = ( + state: RootState +): { courseID: string, [sessionType: string]: string } | undefined => { + if (state.schedules.active === null) return undefined; + return state.schedules.saved[state.schedules.active].hoverSession; +}; + export const reducer = userSchedulesSlice.reducer; diff --git a/apps/frontend/src/app/utils.tsx b/apps/frontend/src/app/utils.tsx index cd4d6c5..e6a93b9 100644 --- a/apps/frontend/src/app/utils.tsx +++ b/apps/frontend/src/app/utils.tsx @@ -2,7 +2,7 @@ import reactStringReplace from "react-string-replace"; import Link from "~/components/Link"; import { FCE, Schedule, Session, Time } from "./types"; import { AggregateFCEsOptions, filterFCEs } from "./fce"; -import { DEPARTMENT_MAP_NAME, DEPARTMENT_MAP_SHORTNAME } from "./constants"; +import { DEPARTMENT_MAP_NAME, DEPARTMENT_MAP_SHORTNAME, CALENDAR_COLORS, CALENDAR_COLORS_LIGHT } from "./constants"; import namecase from "namecase"; export const courseIdRegex = /([0-9]{2}-?[0-9]{3})/g; @@ -20,7 +20,9 @@ export const standardizeIdsInString = (str: string) => { }; export const sessionToString = (sessionInfo: Session | FCE | Schedule) => { - const semester = sessionInfo.semester; + if (!sessionInfo) return ""; + + const semester = sessionInfo?.semester || ""; const sessionStrings = { "summer one": "Summer One", @@ -38,12 +40,14 @@ export const sessionToString = (sessionInfo: Session | FCE | Schedule) => { if (semester === "summer" && sessionInfo.session) { return `${sessionStrings[sessionInfo.session]} ${sessionInfo.year}`; } else { - return `${semesterStrings[sessionInfo.semester]} ${sessionInfo.year}`; + return `${semesterStrings[semester]} ${sessionInfo.year}`; } }; export const sessionToShortString = (sessionInfo: Session | FCE | Schedule) => { - const semester = sessionInfo.semester; + if (!sessionInfo) return ""; + + const semester = sessionInfo?.semester || ""; const sessionStrings = { "summer one": "M1", @@ -61,10 +65,42 @@ export const sessionToShortString = (sessionInfo: Session | FCE | Schedule) => { if (semester === "summer" && sessionInfo.session) { return `${sessionStrings[sessionInfo.session]} ${sessionInfo.year}`; } else { - return `${semesterStrings[sessionInfo.semester]} ${sessionInfo.year}`; + return `${semesterStrings[semester]} ${sessionInfo.year}`; } }; +export const stringToSession = (sessionString: string): Session => { + const sessionStringSplit = sessionString.split(" "); + + if (sessionStringSplit.length === 2) { + const [semester, year] = sessionStringSplit; + return { + semester: semester?.toLowerCase() as any, + year: year as string, + }; + } else if (sessionStringSplit.length === 3) { + const [semester, session, year] = sessionStringSplit; + + if (semester?.includes("Q")) { + return { + semester: "summer", + year: year as string, + session: "qatar summer", + }; + } + return { + semester: semester?.toLowerCase() as any, + year: year as string, + session: `${semester} ${session}`.toLowerCase() as any, + }; + } + + return { + semester: "", + year: "", + }; +} + export const compareSessions = ( session1: Session | FCE, session2: Session | FCE @@ -236,3 +272,10 @@ export function parseUnits(units: string): number { } return 0.0; } + +export const getCalendarColor = (i: number) => CALENDAR_COLORS[i % CALENDAR_COLORS.length] || ""; + +export const getCalendarColorLight = (color: string) => { + const index = CALENDAR_COLORS.indexOf(color); + return CALENDAR_COLORS_LIGHT[index]; +} \ No newline at end of file diff --git a/apps/frontend/src/components/BookmarkButton.tsx b/apps/frontend/src/components/BookmarkButton.tsx index 25c09da..0a81415 100644 --- a/apps/frontend/src/components/BookmarkButton.tsx +++ b/apps/frontend/src/components/BookmarkButton.tsx @@ -1,13 +1,15 @@ -import React from "react"; -import { StarIcon as OutlineStar } from "@heroicons/react/24/outline"; +import React, { useState } from "react"; +import { PlusIcon } from "@heroicons/react/24/solid"; import { useAppDispatch, useAppSelector } from "~/app/hooks"; import { userSlice } from "~/app/user"; +import { CheckIcon } from "@heroicons/react/20/solid"; +import { UserSchedule, userSchedulesSlice } from "~/app/userSchedules"; interface Props { courseID: string; } -const BookmarkButton = ({ courseID }: Props) => { +const BookmarkButton = ({courseID}: Props) => { const dispatch = useAppDispatch(); const bookmarks = useAppSelector((state) => state.user.bookmarked); const bookmarked = bookmarks.indexOf(courseID) !== -1; @@ -17,12 +19,47 @@ const BookmarkButton = ({ courseID }: Props) => { else dispatch(userSlice.actions.addBookmark(courseID)); }; + const [dropdownVisible, setDropdownVisible] = useState(false); + const saved = useAppSelector((state) => state.schedules.saved); + + const toggleCourseInSchedule = (schedule: UserSchedule) => { + dispatch(userSchedulesSlice.actions.changeActiveSchedule(schedule.id)); + if (schedule.courses.includes(courseID)) { + dispatch(userSchedulesSlice.actions.removeCourseFromActiveSchedule(courseID)); + } else { + dispatch(userSchedulesSlice.actions.addCourseToActiveSchedule(courseID)); + } + }; + return ( -
- {bookmarked ? ( - +
setDropdownVisible(true)} onMouseLeave={() => setDropdownVisible(false)}> + {dropdownVisible ? ( +
+
    +
  • + Saved + {bookmarked && ( + + + + )} +
  • + {Object.values(saved).map((schedule) => ( +
  • toggleCourseInSchedule(schedule)}> + {schedule.name} + {schedule.courses.includes(courseID) && ( + + + + )} +
  • + ))} +
+
) : ( - + )}
); diff --git a/apps/frontend/src/components/CourseList.tsx b/apps/frontend/src/components/CourseList.tsx index 18bd619..68b4225 100644 --- a/apps/frontend/src/components/CourseList.tsx +++ b/apps/frontend/src/components/CourseList.tsx @@ -12,7 +12,7 @@ const CourseList = ({ courseIDs, children }: Props) => { const results = useFetchCourseInfos(courseIDs); return ( -
+
{results.length > 0 ? ( <>
diff --git a/apps/frontend/src/components/ScheduleCalendar.tsx b/apps/frontend/src/components/ScheduleCalendar.tsx new file mode 100644 index 0000000..8b16305 --- /dev/null +++ b/apps/frontend/src/components/ScheduleCalendar.tsx @@ -0,0 +1,228 @@ +import React, { useMemo } from "react"; +import { Calendar, DateLocalizer, momentLocalizer } from "react-big-calendar"; +import PropTypes from 'prop-types' +import * as dates from 'date-arithmetic' +import TimeGrid from 'react-big-calendar/lib/TimeGrid' +import Toolbar from 'react-big-calendar/lib/Toolbar' +import style from "react-big-calendar/lib/css/react-big-calendar.css"; +import moment from "moment"; +import { useAppSelector } from "~/app/hooks"; +import { Course, Time } from "~/app/types"; +import {getCalendarColorLight, sessionToString} from "~/app/utils"; +import { + CourseSessions, HoverSession, + selectCourseSessionsInActiveSchedule, + selectHoverSessionInActiveSchedule, + selectSessionInActiveSchedule +} from "~/app/userSchedules"; +import { useFetchCourseInfos } from "~/app/api/course"; + +const localizer = momentLocalizer(moment); + +function Week({ + date, + max = localizer.endOf(new Date(), 'day'), + min = localizer.startOf(new Date(), 'day'), + scrollToTime = localizer.startOf(new Date(), 'day'), + ...props +} : { date: Date, max: Date, min: Date, scrollToTime: Date }) { + const currRange = useMemo( + () => Week.range(date, { localizer }), + [date, localizer] + ) + + return ( + + ) +} + +Week.propTypes = { + date: PropTypes.instanceOf(Date).isRequired, + localizer: PropTypes.object, + max: PropTypes.instanceOf(Date), + min: PropTypes.instanceOf(Date), + scrollToTime: PropTypes.instanceOf(Date), +} + +Week.range = (date: Date, p: { localizer: DateLocalizer }) => { + const start = new Date(2024, 8, 30) + const end = dates.add(start, 4, 'day') + + let current = start + const range = [] + + while (localizer.lte(current, end, 'day')) { + range.push(current) + current = localizer.add(current, 1, 'day') + } + + return range +} + +Week.title = () => "Week" + +class CustomToolbar extends Toolbar { + render() { + return ""; + } +} + +const getTime = (day: number, time: string) => { + let [hour, minute] = time.split(":"); + if (hour && time.slice(-2) === "PM" && time.slice(0, 2) !== "12") { + hour = (parseInt(hour) + 12).toString(); + } + return new Date(2024, 8, 29 + day, parseInt(hour || "0"), parseInt(minute || "0")); +} + +const getTimes = (courseID: string, sessionType: string, sessionTimes: Time[], color: string) => { + const times = []; + for (const sessionTime of sessionTimes || []) { + for (const day of sessionTime.days || []) { + times.push({ + title: `${courseID} ${sessionType}`, + start: getTime(day, sessionTime.begin || ""), + end: getTime(day, sessionTime.end || ""), + color, + }); + } + } + return times; +} + +interface Event { + title: string; + start: Date; + end: Date; + color: string; +} + +const getEvents = (CourseDetails: Course[], selectedSemester: string, selectedSessions: CourseSessions, hoverSession?: HoverSession) => { + let events: Event[] = []; + + const filteredCourses = CourseDetails.filter((course) => { + const schedules = course.schedules; + if (schedules) { + return schedules.some(sched => sessionToString(sched) === selectedSemester); + } + }); + + const selectedLectures = filteredCourses.flatMap(course => { + const lecture = course.schedules?.find(sched => sessionToString(sched) === selectedSemester) + ?.lectures.find(lecture => lecture.name === selectedSessions[course.courseID]?.Lecture); + return { + courseID: course.courseID, + color: selectedSessions[course.courseID]?.Color || "", + ...lecture, + } + }).filter(x => x !== undefined); + + events = events.concat(selectedLectures.flatMap(lecture => { + if (lecture.times) return getTimes(lecture.courseID, lecture.name || "Lecture", lecture.times, lecture.color); + }).filter(x => x !== undefined)); + + const selectedSections = filteredCourses.flatMap(course => { + const section = course.schedules?.find(sched => sessionToString(sched) === selectedSemester) + ?.sections.find(section => section.name === selectedSessions[course.courseID]?.Section); + return { + courseID: course.courseID, + color: selectedSessions[course.courseID]?.Color || "", + ...section, + } + }).filter(x => x !== undefined); + + events = events.concat(selectedSections.flatMap(section => { + if (section.times) return getTimes(section.courseID, `Section ${section.name || ""}`, section.times, section.color); + }).filter(x => x !== undefined)); + + if (hoverSession) { + const courseID = hoverSession.courseID; + const selectedCourse = filteredCourses.find(course => course.courseID === courseID); + + const hoverLecture = selectedCourse?.schedules?.find(sched => sessionToString(sched) === selectedSemester) + ?.lectures.find(lecture => lecture.name === hoverSession["Lecture"]); + + const hoverSection = selectedCourse?.schedules?.find(sched => sessionToString(sched) === selectedSemester) + ?.sections.find(section => section.name === hoverSession["Section"]); + + events = events.filter(event => event.title.slice(0, courseID.length) !== courseID); + + const hoverColor = getCalendarColorLight(`${selectedSessions[courseID]?.Color}`) || ""; + if (hoverLecture) + events.push(...getTimes(courseID, hoverLecture.name || "Lecture", hoverLecture.times, hoverColor)); + + if (hoverSection) + events.push(...getTimes(courseID, `Section ${hoverSection?.name || ""}`, hoverSection?.times, hoverColor)); + } + + return events; +} + +interface Props { + courseIDs: string[]; +} + +const ScheduleCalendar = ({ courseIDs }: Props) =>{ + const selectedSession = useAppSelector(selectSessionInActiveSchedule); + const selectedCourseSessions = useAppSelector(selectCourseSessionsInActiveSchedule); + const hoverSession = useAppSelector(selectHoverSessionInActiveSchedule); + + const CourseDetails = useFetchCourseInfos(courseIDs); + + const events = getEvents(CourseDetails, selectedSession, selectedCourseSessions, hoverSession); + + const days = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"]; + + const { defaultDate, views, components, formats, eventPropGetter } = useMemo( + () => ({ + defaultDate: new Date(2015, 3, 1), + views: { + week: Week, + }, + components: { + toolbar: CustomToolbar, + header: ({ date } : { date: Date }) =>
{days[moment(date).day()]}
, + }, + formats: { + eventTimeRangeFormat: () => { + return "" + }, + }, + eventPropGetter: (event: Event) => ({ + style: { + color: "#030712", + backgroundColor: event.color, + }, + }), + }), []); + + return ( +
+ +
+ ) +} + +export default ScheduleCalendar; \ No newline at end of file diff --git a/apps/frontend/src/components/ScheduleData.tsx b/apps/frontend/src/components/ScheduleData.tsx index 131c434..28a3390 100644 --- a/apps/frontend/src/components/ScheduleData.tsx +++ b/apps/frontend/src/components/ScheduleData.tsx @@ -98,7 +98,7 @@ const ScheduleData = ({ scheduled }: ScheduleDataProps) => { return ( <>
-
+
Total Workload{" "} @@ -115,7 +115,7 @@ const ScheduleData = ({ scheduled }: ScheduleDataProps) => { dispatch(uiSlice.actions.toggleSchedulesTopbarOpen()) } > -
+
{open ? ( <>
Hide
diff --git a/apps/frontend/src/components/ScheduleSearch.tsx b/apps/frontend/src/components/ScheduleSearch.tsx index 964de32..c9e2740 100644 --- a/apps/frontend/src/components/ScheduleSearch.tsx +++ b/apps/frontend/src/components/ScheduleSearch.tsx @@ -141,8 +141,8 @@ const CourseCombobox = ({
-
-
+
+
{selectedItems.map((selectedItem, index) => (
{ e.stopPropagation(); removeSelectedItem(selectedItem); + dispatch(userSchedulesSlice.actions.removeCourseFromActiveSchedule(selectedItem.courseID)); }} > ✕ @@ -222,7 +223,7 @@ const CourseCombobox = ({ }, })} > - + {course.courseID} diff --git a/apps/frontend/src/components/SectionSelector.tsx b/apps/frontend/src/components/SectionSelector.tsx new file mode 100644 index 0000000..077a2b5 --- /dev/null +++ b/apps/frontend/src/components/SectionSelector.tsx @@ -0,0 +1,227 @@ +import React, { useEffect } from "react"; +import { useAppDispatch, useAppSelector } from "~/app/hooks"; +import { ChevronUpDownIcon } from "@heroicons/react/24/outline"; +import { Listbox, RadioGroup } from "@headlessui/react"; +import { classNames, compareSessions, sessionToString, stringToSession } from "~/app/utils"; +import { CheckIcon } from "@heroicons/react/20/solid"; +import { Schedule } from "~/app/types"; +import { + selectCourseSessionsInActiveSchedule, + selectSessionInActiveSchedule, + userSchedulesSlice +} from "~/app/userSchedules"; +import { useFetchCourseInfos } from "~/app/api/course"; +import { userSlice } from "~/app/user"; +import { SCHED_VIEW } from "~/app/constants"; +import { FlushedButton } from "~/components/Buttons"; + +const SectionSelector = ({ courseIDs }: { courseIDs: string[] }) => { + const dispatch = useAppDispatch(); + const courseDetails = useFetchCourseInfos(courseIDs); + + const selectedSession = useAppSelector(selectSessionInActiveSchedule); + const selectedCourseSessions = useAppSelector(selectCourseSessionsInActiveSchedule); + const scheduleView = useAppSelector((state) => state.user.scheduleView); + + const semesters = [...new Set(courseDetails.flatMap(course => { + const schedules: Schedule[] = course.schedules; + if (schedules) { + return schedules.map(schedule => sessionToString(schedule)); + } + }))].sort((a, b) => { + return compareSessions(stringToSession(a || ""), stringToSession(b || "")); + }); + + const coursesNotInSemester = courseIDs.filter((courseID) => { + const course = courseDetails.find(course => course.courseID === courseID); + const schedules: Schedule[] = course?.schedules || []; + return !schedules.some(schedule => sessionToString(schedule) === selectedSession); + }); + + useEffect(() => { + // Check if selected session is in the list of semesters + if (selectedSession.length > 0 && !semesters.includes(selectedSession)) { + dispatch(userSchedulesSlice.actions.updateActiveScheduleSession(stringToSession(""))); + } + }, [selectedSession, semesters]) + + return ( +
+
+
Schedule Calendar
+ dispatch(userSlice.actions.toggleScheduleView())}> + {scheduleView === SCHED_VIEW ? "Show" : "Hide"} + +
+
+ { + dispatch(userSchedulesSlice.actions.updateActiveScheduleSession(stringToSession(payload))); + }}> + + Semester + + + + {semesters.length === 0 ? ( + Add courses + ) : selectedSession.length === 0 ? ( + Select Semester + ) : ( + + {selectedSession} + + )} + + + + + +
+ + {semesters.map((semester) => ( + { + return classNames( + "relative cursor-pointer select-none py-2 pl-3 pr-9 focus:outline-none ", + active ? "bg-indigo-600 text-gray-600" : "text-gray-900" + ); + }} + > + {({selected}) => ( + <> + + + {semester} + + + {selected && ( + + + + )} + + )} + + ))} + +
+
+
+ {selectedSession.length > 0 && coursesNotInSemester.length > 0 && + `The following courses are not offered in the selected semester: ${coursesNotInSemester.join(", ")}.`} +
+
+
+ { + courseDetails.filter((course) => course.schedules?.some((sched: Schedule) => sessionToString(sched) === selectedSession)).map((course) => { + const schedule: Schedule = course.schedules?.find((sched: Schedule) => sessionToString(sched) === selectedSession); + const courseID = course.courseID; + + const sessionType = schedule.sections ? "Section" : "Lecture"; + const sessions = schedule.sections || schedule.lectures + + const selectedCourseSession = selectedCourseSessions[courseID]?.[sessionType] || ""; + + return ( +
+
+ {courseID} (Select {sessionType}) + { + e.stopPropagation(); + dispatch(userSchedulesSlice.actions.removeCourseFromActiveSchedule(courseID)); + }} + > + ✕ + +
+
+ { + if (sessionType === "Section") { + const section = schedule.sections.find((section) => section.name === payload); + const lecture = schedule.lectures.find((lecture) => lecture.name === section?.lecture); + if (lecture) + dispatch(userSchedulesSlice.actions.updateActiveScheduleCourseSession({ + courseID, + sessionType: "Lecture", + session: lecture.name + })); + } + dispatch(userSchedulesSlice.actions.updateActiveScheduleCourseSession({ + courseID, + sessionType, + session: payload as string + })); + dispatch(userSchedulesSlice.actions.clearHoverSession()); + }} + onMouseLeave={() => { + dispatch(userSchedulesSlice.actions.clearHoverSession()); + }} + > + {sessions.map((session, i) => ( + { + return classNames( + "flex relative justify-center cursor-pointer select-none focus:outline-none", + "hover:bg-gray-200 py-1", + i === 0 ? "rounded-l-md pl-1" : "", + i === sessions.length - 1 ? "rounded-r-md pr-1" : "", + active ? "bg-indigo-600 text-gray-600" : "text-gray-900" + ); + }} + onMouseEnter={() => { + const payload = { courseID, "Lecture": "", "Section": "" }; + if (sessionType === "Lecture") { + payload["Lecture"] = session.name; + } + if (sessionType === "Section") { + payload["Section"] = session.name; + const section = schedule.sections.find((section) => section.name === session.name); + const lecture = schedule.lectures.find((lecture) => lecture.name === section?.lecture); + if (lecture) + payload["Lecture"] = lecture.name; + } + dispatch(userSchedulesSlice.actions.setHoverSession(payload)); + }} + > + {({checked}) => ( + + + {session.name} + + + )} + + ))} + +
+
+ ); + }) + } +
+
+
+ ); +}; + +export default SectionSelector; \ No newline at end of file diff --git a/apps/frontend/src/components/Sidebar.tsx b/apps/frontend/src/components/Sidebar.tsx index ff9ef68..9bee19c 100644 --- a/apps/frontend/src/components/Sidebar.tsx +++ b/apps/frontend/src/components/Sidebar.tsx @@ -14,7 +14,7 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => { const dispatch = useAppDispatch(); return open ? ( -
+
-
+
Hide
@@ -34,7 +34,7 @@ const Sidebar = ({ children }: { children: React.ReactNode }) => {
) : ( -
+