Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions site/src/app/roadmap/catalog/ProgramRequirementsList.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
64 changes: 51 additions & 13 deletions site/src/app/roadmap/catalog/ProgramRequirementsList.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand Down Expand Up @@ -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<Record<string, string[]>>({});

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;
}
Expand All @@ -67,11 +82,14 @@ interface CourseTileProps {
dragTimestamp?: number;
}
const CourseTile: FC<CourseTileProps> = ({ courseID, completedBy, dragTimestamp = 0 }) => {
const courseTermsMap = useContext(CourseTermsContext);
const terms = courseTermsMap[courseID];
const [courseData, setCourseData] = useState<string | CourseGQLData>(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;
Expand Down Expand Up @@ -109,7 +127,9 @@ const CourseTile: FC<CourseTileProps> = ({ 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) {
Expand Down Expand Up @@ -550,6 +570,22 @@ const ProgramRequirementsList: FC<RequireCourseListProps> = ({
}) => {
const formattedRequirements = formatRequirements(requirements, skipCollapseSingletons);
const transferredCourses = useTransferredCredits().courses;

const [courseTermsMap, setCourseTermsMap] = useState<Record<string, string[]>>({});
const fetchedIDs = useRef<Set<string>>(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;
Expand All @@ -571,17 +607,19 @@ const ProgramRequirementsList: FC<RequireCourseListProps> = ({
);

return (
<div className="program-requirements">
{/* key is ok because we don't reorder these */}
{formattedRequirements.map((r, i) => (
<ProgramRequirementDisplay
requirement={r}
key={i}
storeKey={`${storeKeyPrefix}-${i}`}
takenCourseIDs={takenCourseSet}
/>
))}
</div>
<CourseTermsContext.Provider value={courseTermsMap}>
<div className="program-requirements">
{/* key is ok because we don't reorder these */}
{formattedRequirements.map((r, i) => (
<ProgramRequirementDisplay
requirement={r}
key={i}
storeKey={`${storeKeyPrefix}-${i}`}
takenCourseIDs={takenCourseSet}
/>
))}
</div>
</CourseTermsContext.Provider>
);
};

Expand Down
8 changes: 8 additions & 0 deletions site/src/app/roadmap/planner/Course.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
8 changes: 6 additions & 2 deletions site/src/app/roadmap/planner/Course.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,12 @@ interface CourseProps {
addMode?: 'tap' | 'drag';
data: PlannerCourseData;
onSetVariableUnits?: (units: number | undefined) => void;
dimmed?: boolean;
}

const Course: FC<CourseProps> = (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();
Expand Down Expand Up @@ -115,7 +116,10 @@ const Course: FC<CourseProps> = (props) => {
}, [userChosenUnits]);

return (
<div className={`course ${isInRoadmap ? 'roadmap-course' : ''}`} {...tappableCourseProps}>
<div
className={`course ${isInRoadmap ? 'roadmap-course' : ''}${dimmed ? ' course-dimmed' : ''}`}
{...tappableCourseProps}
>
{(!isMobile || isInRoadmap) && (
<div className="course-drag-handle">
<DragIndicatorIcon />
Expand Down
6 changes: 5 additions & 1 deletion site/src/app/roadmap/search/SavedAndSearch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -49,14 +50,17 @@ 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,
course.prerequisiteTree,
coursesInCurrentQuarter,
);

return <Course data={course} addMode={addMode} requiredCourses={missingPrerequisites} />;
const isDimmed = quarterFilters.length > 0 && !courseMatchesQuarterFilter(course.terms, quarterFilters);

return <Course data={course} addMode={addMode} requiredCourses={missingPrerequisites} dimmed={isDimmed} />;
};

const CourseResultsContainer: FC<CourseResultsContainerProps> = ({ searchResults }) => {
Expand Down
6 changes: 4 additions & 2 deletions site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.scss
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -26,7 +28,7 @@
}

.sidebar-content {
height: 100%;
flex: 1;
overflow: auto;
padding: 16px 20px;

Expand Down
2 changes: 2 additions & 0 deletions site/src/app/roadmap/sidebar/DesktopRoadmapSidebar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -50,6 +51,7 @@ const DesktopRoadmapSidebar = () => {
<Tab icon={<SearchIcon />} iconPosition="start" label="Search" />
</Tabs>

{(selectedIndex === 1 || selectedIndex === 2) && <QuarterFilterBar />}
<div className="sidebar-content" id="sidebarScrollContainer">
{selectedIndex === 0 && <TransferCreditsMenu />}
{selectedIndex === 1 && <CourseCatalog />}
Expand Down
43 changes: 43 additions & 0 deletions site/src/component/QuarterFilterBar/QuarterFilterBar.scss
Original file line number Diff line number Diff line change
@@ -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;
}
}
}
36 changes: 36 additions & 0 deletions site/src/component/QuarterFilterBar/QuarterFilterBar.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="quarter-filter-bar">
{QUARTERS.map(({ name, emoji }) => (
<button
key={name}
className={`quarter-filter-btn${quarterFilters.includes(name) ? ' active' : ''}`}
onClick={() => dispatch(toggleQuarterFilter(name))}
title={`Filter by ${name}`}
>
<span className="quarter-emoji">{emoji}</span>
<span className="quarter-name">{name}</span>
</button>
))}
</div>
);
};

export default QuarterFilterBar;
33 changes: 33 additions & 0 deletions site/src/helpers/quarterFilter.ts
Original file line number Diff line number Diff line change
@@ -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));
}
24 changes: 24 additions & 0 deletions site/src/store/slices/filtersSlice.ts
Original file line number Diff line number Diff line change
@@ -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<QuarterFilterName>) => {
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;
2 changes: 2 additions & 0 deletions site/src/store/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -28,6 +29,7 @@ const reducer = {
departments: departmentsReducer,
professors: professorSliceReducer,
customCourses: customCoursesReducer,
filters: filtersReducer,
};

export function generateStore(user: UserSliceState) {
Expand Down