diff --git a/site/src/app/roadmap/catalog/ProgramRequirementsList.scss b/site/src/app/roadmap/catalog/ProgramRequirementsList.scss index d391523d1..d49488b26 100644 --- a/site/src/app/roadmap/catalog/ProgramRequirementsList.scss +++ b/site/src/app/roadmap/catalog/ProgramRequirementsList.scss @@ -214,6 +214,14 @@ color: inherit; } + &.quarter-filter-dimmed { + opacity: 0.35; + + a { + text-decoration: line-through; + } + } + &.completed { background-color: var(--mui-palette-overlay-overlay2); outline: 1.5px solid var(--mui-palette-text-secondary); diff --git a/site/src/app/roadmap/catalog/ProgramRequirementsList.tsx b/site/src/app/roadmap/catalog/ProgramRequirementsList.tsx index a0a92ec49..cc870689d 100644 --- a/site/src/app/roadmap/catalog/ProgramRequirementsList.tsx +++ b/site/src/app/roadmap/catalog/ProgramRequirementsList.tsx @@ -1,5 +1,5 @@ import './ProgramRequirementsList.scss'; -import React, { FC, useCallback, useEffect, useState } from 'react'; +import React, { createContext, FC, useCallback, useContext, useEffect, useRef, useState } from 'react'; import { Button, Dialog, DialogActions, DialogContent, DialogContentText, DialogTitle } from '@mui/material'; import { COMPLETE_ALL_TEXT, @@ -40,12 +40,27 @@ import { useTransferredCredits, TransferredCourseWithType } from '../../../hooks import { useIsLoggedIn } from '../../../hooks/isLoggedIn'; import SwapHorizOutlinedIcon from '@mui/icons-material/SwapHorizOutlined'; import { Badge, Checkbox, Collapse } from '@mui/material'; +import { courseMatchesQuarterFilter } from '../../../helpers/quarterFilter'; import { ExpandMore } from '../../../component/ExpandMore/ExpandMore'; import SwapHorizIcon from '@mui/icons-material/SwapHoriz'; import MenuTile from '../transfers/MenuTile'; import { setShowMobileCreditsMenu } from '../../../store/slices/transferCreditsSlice'; import ClickableDiv from '../../../component/ClickableDiv/ClickableDiv'; +const CourseTermsContext = createContext>({}); + +function collectCourseIDs(requirements: ProgramRequirement[]): string[] { + const ids: string[] = []; + for (const req of requirements) { + if (req.requirementType === 'Course' || req.requirementType === 'Unit') { + ids.push(...req.courses); + } else if (req.requirementType === 'Group') { + ids.push(...collectCourseIDs(req.requirements)); + } + } + return ids; +} + interface SourceOverlayProps { completedBy: TransferredCourseWithType['transferType'] | 'roadmap' | null; } @@ -67,11 +82,14 @@ interface CourseTileProps { dragTimestamp?: number; } const CourseTile: FC = ({ courseID, completedBy, dragTimestamp = 0 }) => { + const courseTermsMap = useContext(CourseTermsContext); + const terms = courseTermsMap[courseID]; const [courseData, setCourseData] = useState(courseID); const [loading, setLoading] = useState(false); const isMobile = useIsMobile(); const clearedCourses = useClearedCourses(); const dispatch = useAppDispatch(); + const quarterFilters = useAppSelector((state) => state.filters.quarterFilters); const loadFullData = useCallback(async () => { if (typeof courseData !== 'string') return courseData; @@ -109,7 +127,9 @@ const CourseTile: FC = ({ courseID, completedBy, dragTimestamp const tapProps = { onClick: insertCourseOnClick, role: 'button', tabIndex: 0 }; const tappableCourseProps = isMobile ? tapProps : {}; - const className = `program-course-tile${isMobile ? ' mobile' : ''}${loading ? ' loading' : ''}${completedBy ? ' completed' : ''}`; + const isDimmed = + quarterFilters.length > 0 && terms !== undefined && !courseMatchesQuarterFilter(terms, quarterFilters); + const className = `program-course-tile${isMobile ? ' mobile' : ''}${loading ? ' loading' : ''}${completedBy ? ' completed' : ''}${isDimmed ? ' quarter-filter-dimmed' : ''}`; let fontSize: string | undefined; if (courseID.length > 10) { @@ -550,6 +570,22 @@ const ProgramRequirementsList: FC = ({ }) => { const formattedRequirements = formatRequirements(requirements, skipCollapseSingletons); const transferredCourses = useTransferredCredits().courses; + + const [courseTermsMap, setCourseTermsMap] = useState>({}); + const fetchedIDs = useRef>(new Set()); + useEffect(() => { + const allCourseIDs = collectCourseIDs(requirements); + const uncached = allCourseIDs.filter((id) => !fetchedIDs.current.has(id)); + if (uncached.length === 0) return; + uncached.forEach((id) => fetchedIDs.current.add(id)); + trpc.courses.batch.mutate({ courses: uncached }).then((data) => { + setCourseTermsMap((prev) => { + const next = { ...prev }; + for (const [id, course] of Object.entries(data)) next[id] = course.terms; + return next; + }); + }); + }, [requirements]); const roadmapPlans = useAppSelector((state) => state.roadmap.plans); const roadmapPlanIndex = useAppSelector((state) => state.roadmap.currentPlanIndex); const yearPlans = roadmapPlans[roadmapPlanIndex].content.yearPlans; @@ -571,17 +607,19 @@ const ProgramRequirementsList: FC = ({ ); return ( -
- {/* key is ok because we don't reorder these */} - {formattedRequirements.map((r, i) => ( - - ))} -
+ +
+ {/* key is ok because we don't reorder these */} + {formattedRequirements.map((r, i) => ( + + ))} +
+
); }; diff --git a/site/src/app/roadmap/planner/Course.scss b/site/src/app/roadmap/planner/Course.scss index 7d85d7a23..37da5ac25 100644 --- a/site/src/app/roadmap/planner/Course.scss +++ b/site/src/app/roadmap/planner/Course.scss @@ -154,6 +154,14 @@ } } +.course.course-dimmed { + opacity: 0.4; + + .name { + text-decoration: line-through; + } +} + .course.sortable-fallback { height: fit-content !important; animation: course-fade-in 0.2s forwards; diff --git a/site/src/app/roadmap/planner/Course.tsx b/site/src/app/roadmap/planner/Course.tsx index 00fcfd857..7c77f7178 100644 --- a/site/src/app/roadmap/planner/Course.tsx +++ b/site/src/app/roadmap/planner/Course.tsx @@ -80,11 +80,12 @@ interface CourseProps { addMode?: 'tap' | 'drag'; data: PlannerCourseData; onSetVariableUnits?: (units: number | undefined) => void; + dimmed?: boolean; } const Course: FC = (props) => { const { title, courseLevel, minUnits, maxUnits, terms, geList, userChosenUnits } = props.data; - const { requiredCourses, quarterMismatch, onDelete, openPopoverLeft, onSetVariableUnits } = props; + const { requiredCourses, quarterMismatch, onDelete, openPopoverLeft, onSetVariableUnits, dimmed } = props; const isInRoadmap = !!onDelete; const isMobile = useIsMobile(); @@ -115,7 +116,10 @@ const Course: FC = (props) => { }, [userChosenUnits]); return ( -
+
{(!isMobile || isInRoadmap) && (
diff --git a/site/src/app/roadmap/search/SavedAndSearch.tsx b/site/src/app/roadmap/search/SavedAndSearch.tsx index 385a439fa..735564488 100644 --- a/site/src/app/roadmap/search/SavedAndSearch.tsx +++ b/site/src/app/roadmap/search/SavedAndSearch.tsx @@ -10,6 +10,7 @@ import { ReactSortable, SortableEvent } from 'react-sortablejs'; import { setActiveCourse } from '../../../store/slices/roadmapSlice'; import { getMissingPrerequisites } from '../../../helpers/planner'; import { courseSearchSortable } from '../../../helpers/sortable'; +import { courseMatchesQuarterFilter } from '../../../helpers/quarterFilter'; import Course from '../planner/Course'; import LoadingSpinner from '../../../component/LoadingSpinner/LoadingSpinner'; import NoResults from '../../../component/NoResults/NoResults'; @@ -49,6 +50,7 @@ const CourseResultItem: FC<{ course: CourseGQLData; addMode: 'tap' | 'drag' }> = const courseId = `${course.department} ${course.courseNumber}`; const clearedCourses = useClearedCoursesUntil(courseId); const coursesInCurrentQuarter = useGetCoursesInSameQuarter(courseId); + const quarterFilters = useAppSelector((state) => state.filters.quarterFilters); const missingPrerequisites = getMissingPrerequisites( clearedCourses, @@ -56,7 +58,9 @@ const CourseResultItem: FC<{ course: CourseGQLData; addMode: 'tap' | 'drag' }> = coursesInCurrentQuarter, ); - return ; + const isDimmed = quarterFilters.length > 0 && !courseMatchesQuarterFilter(course.terms, quarterFilters); + + return ; }; const CourseResultsContainer: FC = ({ searchResults }) => { diff --git a/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.scss b/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.scss index 37fd5f937..c8e62ac41 100644 --- a/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.scss +++ b/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.scss @@ -3,13 +3,15 @@ .roadmap-sidebar { background-color: var(--mui-palette-overlay-overlay1); height: 100%; - overflow: auto; + overflow: hidden; width: globals.$roadmap-sidebar-width; flex-shrink: 0; padding-top: globals.$roadmap-toolbar-height; position: relative; border-right: 1px solid var(--mui-palette-divider); z-index: 50; + display: flex; + flex-direction: column; .sidebar-tabs { position: absolute; @@ -26,7 +28,7 @@ } .sidebar-content { - height: 100%; + flex: 1; overflow: auto; padding: 16px 20px; diff --git a/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.tsx b/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.tsx index 0adbaa8e3..7b42a0f2d 100644 --- a/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.tsx +++ b/site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.tsx @@ -13,6 +13,7 @@ import { useHasUnreadTransfers } from '../../../hooks/transferCredits'; import { useAppDispatch, useAppSelector } from '../../../store/hooks'; import { clearUnreadTransfers } from '../../../store/slices/transferCreditsSlice'; import { setSelectedSidebarTab } from '../../../store/slices/roadmapSlice'; +import QuarterFilterBar from '../../../component/QuarterFilterBar/QuarterFilterBar'; const DesktopRoadmapSidebar = () => { const selectedIndex = useAppSelector((state) => state.roadmap.selectedSidebarTab); @@ -50,6 +51,7 @@ const DesktopRoadmapSidebar = () => { } iconPosition="start" label="Search" /> + {(selectedIndex === 1 || selectedIndex === 2) && }
{selectedIndex === 0 && } {selectedIndex === 1 && } diff --git a/site/src/component/QuarterFilterBar/QuarterFilterBar.scss b/site/src/component/QuarterFilterBar/QuarterFilterBar.scss new file mode 100644 index 000000000..245d18d12 --- /dev/null +++ b/site/src/component/QuarterFilterBar/QuarterFilterBar.scss @@ -0,0 +1,43 @@ +.quarter-filter-bar { + display: flex; + align-items: center; + justify-content: space-evenly; + gap: 6px; + padding: 6px 12px; + border-bottom: 1px solid var(--mui-palette-divider); + background-color: var(--mui-palette-background-paper); + flex-shrink: 0; + + .quarter-filter-btn { + display: flex; + align-items: center; + gap: 4px; + padding: 4px 10px; + border: 1px solid var(--mui-palette-divider); + border-radius: 16px; + background: transparent; + cursor: pointer; + font-size: 13px; + color: var(--mui-palette-text-secondary); + transition: + background-color 0.15s, + color 0.15s, + border-color 0.15s; + white-space: nowrap; + + &:hover { + background-color: var(--mui-palette-action-hover); + } + + &.active { + background-color: var(--mui-palette-primary-main); + color: var(--mui-palette-primary-contrastText); + border-color: var(--mui-palette-primary-main); + } + + .quarter-emoji { + font-size: 15px; + line-height: 1; + } + } +} diff --git a/site/src/component/QuarterFilterBar/QuarterFilterBar.tsx b/site/src/component/QuarterFilterBar/QuarterFilterBar.tsx new file mode 100644 index 000000000..3b43b9859 --- /dev/null +++ b/site/src/component/QuarterFilterBar/QuarterFilterBar.tsx @@ -0,0 +1,36 @@ +'use client'; +import './QuarterFilterBar.scss'; +import { FC } from 'react'; +import { useAppDispatch, useAppSelector } from '../../store/hooks'; +import { QuarterFilterName } from '../../helpers/quarterFilter'; +import { toggleQuarterFilter } from '../../store/slices/filtersSlice'; + +const QUARTERS: { name: QuarterFilterName; emoji: string }[] = [ + { name: 'Fall', emoji: '🍂' }, + { name: 'Winter', emoji: '❄️' }, + { name: 'Spring', emoji: '🌸' }, + { name: 'Summer', emoji: '☀️' }, +]; + +const QuarterFilterBar: FC = () => { + const dispatch = useAppDispatch(); + const quarterFilters = useAppSelector((state) => state.filters.quarterFilters); + + return ( +
+ {QUARTERS.map(({ name, emoji }) => ( + + ))} +
+ ); +}; + +export default QuarterFilterBar; diff --git a/site/src/helpers/quarterFilter.ts b/site/src/helpers/quarterFilter.ts new file mode 100644 index 000000000..eb135b02b --- /dev/null +++ b/site/src/helpers/quarterFilter.ts @@ -0,0 +1,33 @@ +export type QuarterFilterName = 'Fall' | 'Winter' | 'Spring' | 'Summer'; + +const searchPrevYears = 2; + +function quarterOfferedRecently(terms: string[], quarter: QuarterFilterName): boolean { + const currentDate = new Date(); + const currentStartYear = currentDate.getMonth() >= 8 ? currentDate.getFullYear() : currentDate.getFullYear() - 1; + const termYear = quarter === 'Fall' ? currentStartYear : currentStartYear + 1; + + for (let i = 0; i <= searchPrevYears; i++) { + const year = termYear - i; + if (quarter === 'Summer') { + if ( + terms.includes(`${year} Summer10wk`) || + terms.includes(`${year} Summer1`) || + terms.includes(`${year} Summer2`) + ) { + return true; + } + } else { + if (terms.includes(`${year} ${quarter}`)) { + return true; + } + } + } + return false; +} + +export function courseMatchesQuarterFilter(terms: string[], quarterFilters: QuarterFilterName[]): boolean { + if (quarterFilters.length === 0) return true; + if (terms.length === 0) return false; + return quarterFilters.some((filter) => quarterOfferedRecently(terms, filter)); +} diff --git a/site/src/store/slices/filtersSlice.ts b/site/src/store/slices/filtersSlice.ts new file mode 100644 index 000000000..9566bf977 --- /dev/null +++ b/site/src/store/slices/filtersSlice.ts @@ -0,0 +1,24 @@ +import { createSlice, PayloadAction } from '@reduxjs/toolkit'; +import { QuarterFilterName } from '../../helpers/quarterFilter'; + +export const filtersSlice = createSlice({ + name: 'filters', + initialState: { + quarterFilters: [] as QuarterFilterName[], + }, + reducers: { + toggleQuarterFilter: (state, action: PayloadAction) => { + const quarter = action.payload; + const idx = state.quarterFilters.indexOf(quarter); + if (idx === -1) { + state.quarterFilters.push(quarter); + } else { + state.quarterFilters.splice(idx, 1); + } + }, + }, +}); + +export const { toggleQuarterFilter } = filtersSlice.actions; + +export default filtersSlice.reducer; diff --git a/site/src/store/store.ts b/site/src/store/store.ts index d830028da..4dfb04a1d 100644 --- a/site/src/store/store.ts +++ b/site/src/store/store.ts @@ -13,6 +13,7 @@ import departmentsReducer from './slices/departmentsSlice'; import { UserSliceState } from '@peterportal/types'; import professorSliceReducer from './slices/professorSlice'; import customCoursesReducer from './slices/customCourseSlice'; +import filtersReducer from './slices/filtersSlice'; const reducer = { preview: previewReducer, @@ -28,6 +29,7 @@ const reducer = { departments: departmentsReducer, professors: professorSliceReducer, customCourses: customCoursesReducer, + filters: filtersReducer, }; export function generateStore(user: UserSliceState) {