diff --git a/app/schedule/_components/LazyScheduleContent.tsx b/app/schedule/_components/LazyScheduleContent.tsx
index be2ae1eb..c789d1a6 100644
--- a/app/schedule/_components/LazyScheduleContent.tsx
+++ b/app/schedule/_components/LazyScheduleContent.tsx
@@ -16,6 +16,12 @@ const ScheduleContent = dynamic(() => import('./ScheduleContent'), {
),
});
-export default function LazyScheduleContent() {
- return ;
+import { Course, TCourseId } from '@/lib/types';
+
+interface LazyScheduleContentProps {
+ allCourseData: Record;
+}
+
+export default function LazyScheduleContent({ allCourseData }: LazyScheduleContentProps) {
+ return ;
}
diff --git a/app/schedule/_components/ScheduleContent.tsx b/app/schedule/_components/ScheduleContent.tsx
index f783a643..f62c68f6 100644
--- a/app/schedule/_components/ScheduleContent.tsx
+++ b/app/schedule/_components/ScheduleContent.tsx
@@ -1,6 +1,7 @@
'use client';
import { useState, useEffect, useMemo, useRef } from 'react';
+import Link from 'next/link';
import {
Container,
Title,
@@ -22,6 +23,8 @@ import {
CopyButton,
Select,
TextInput,
+ Switch,
+ UnstyledButton,
} from '@mantine/core';
import {
IconSearch,
@@ -36,7 +39,11 @@ import {
IconBookmark,
IconCopy,
IconCheck,
+ IconChevronUp,
+ IconChevronDown,
+ IconSelector,
} from '@tabler/icons-react';
+import { Course, TCourseId } from '@/lib/types';
import { GT_COLORS } from '@/lib/theme';
import {
getFutureCandidates,
@@ -187,6 +194,11 @@ interface CourseSection {
seatsAvailable: number;
waitlist: number;
url: string | null;
+ isFoundational?: boolean;
+ avgDifficulty?: number | null;
+ avgWorkload?: number | null;
+ avgOverall?: number | null;
+ numReviews?: number;
}
interface StatCardProps {
@@ -203,9 +215,13 @@ interface GroupedScheduleSections {
freeElectives: CourseSection[];
}
+interface ScheduleContentProps {
+ allCourseData?: Partial>;
+}
+
const scheduleTableHeaderHeight = sharedTableHeaderHeight;
-const currentScheduleTableColumns = '100px 85px minmax(295px, 1.8fr) minmax(165px, 1fr) 135px 80px';
-const historicalScheduleTableColumns = '100px minmax(295px, 1.8fr) minmax(165px, 1fr) 135px 80px';
+const currentScheduleTableColumns = '100px 85px minmax(320px, 2.5fr) minmax(140px, 1.5fr) 105px 100px 95px 135px 80px';
+const historicalScheduleTableColumns = '100px minmax(320px, 2.5fr) minmax(140px, 1.5fr) 105px 100px 95px 135px 80px';
export function getScheduleTablePaperProps() {
return getSharedTablePaperProps();
@@ -327,7 +343,29 @@ function getSearchScore(section: CourseSection, query: string): number {
return 0; // No match
}
-export default function ScheduleContent() {
+type SortField = 'courseId' | 'difficulty' | 'workload' | 'overall';
+
+function SortableHeader({ children, reversed, sorted, onSort, ta = 'left', px = 'sm', py = 'sm' }: any) {
+ const Icon = sorted ? (reversed ? IconChevronUp : IconChevronDown) : IconSelector;
+ return (
+
+ {onSort ? (
+
+
+ {children}
+
+
+
+ ) : (
+
+ {children}
+
+ )}
+
+ );
+}
+
+export default function ScheduleContent({ allCourseData = {} }: ScheduleContentProps) {
const pastSemesters = useMemo(() => getPastSemesters(), []);
const initialActiveSemester = getInitialActiveSemester(pastSemesters);
const [semesters, setSemesters] = useState(pastSemesters);
@@ -335,6 +373,9 @@ export default function ScheduleContent() {
const [latestAvailableSemester, setLatestAvailableSemester] = useState('');
const [isDiscoveringSemester, setIsDiscoveringSemester] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
+ const [showFoundationalOnly, setShowFoundationalOnly] = useState(false);
+ const [sortBy, setSortBy] = useState(null);
+ const [reverseSortDirection, setReverseSortDirection] = useState(false);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
const [sections, setSections] = useState([]);
@@ -478,6 +519,7 @@ export default function ScheduleContent() {
const sections = courseData.sections || [];
for (const section of sections) {
+ const extraData = allCourseData?.[courseId as TCourseId] || ({} as Partial);
combinedSections.push({
crn: section.crn,
courseId,
@@ -490,6 +532,11 @@ export default function ScheduleContent() {
seatsAvailable: section.seatsAvailable,
waitlist: section.waitCount || 0,
url: courseInfo?.url || null,
+ isFoundational: courseInfo?.isFoundational || extraData?.isFoundational || false,
+ avgDifficulty: extraData?.avgDifficulty || null,
+ avgWorkload: extraData?.avgWorkload || null,
+ avgOverall: extraData?.avgOverall || null,
+ numReviews: extraData?.numReviews || 0,
});
}
}
@@ -559,16 +606,49 @@ export default function ScheduleContent() {
return [];
}
- const scored = sections.map((section) => ({
+ const filteredByFoundational = showFoundationalOnly
+ ? sections.filter(s => s.isFoundational)
+ : sections;
+
+ const scored = filteredByFoundational.map((section) => ({
section,
score: getSearchScore(section, searchQuery),
}));
return scored
.filter((item) => item.score > 0)
- .sort((a, b) => b.score - a.score)
+ .sort((a, b) => {
+ if (searchQuery && b.score !== a.score) {
+ return b.score - a.score;
+ }
+
+ let comparison = 0;
+ switch (sortBy) {
+ case 'courseId':
+ comparison = a.section.courseId.localeCompare(b.section.courseId);
+ break;
+ case 'difficulty':
+ comparison = (a.section.avgDifficulty || 0) - (b.section.avgDifficulty || 0);
+ break;
+ case 'workload':
+ comparison = (a.section.avgWorkload || 0) - (b.section.avgWorkload || 0);
+ break;
+ case 'overall':
+ comparison = (a.section.avgOverall || 0) - (b.section.avgOverall || 0);
+ break;
+ default:
+ comparison = a.section.courseId.localeCompare(b.section.courseId);
+ }
+ return reverseSortDirection ? -comparison : comparison;
+ })
.map((item) => item.section);
- }, [sections, searchQuery]);
+ }, [sections, searchQuery, showFoundationalOnly, sortBy, reverseSortDirection]);
+
+ const setSorting = (field: SortField) => {
+ const reversed = field === sortBy ? !reverseSortDirection : false;
+ setSortBy(field);
+ setReverseSortDirection(reversed);
+ };
// Group sections by core/elective/free when specialization is selected
const groupedSections = useMemo(() => {
@@ -627,6 +707,55 @@ export default function ScheduleContent() {
return { status: 'Open', color: GT_COLORS.canopyLime, description: `${seatsAvailable} seats available` };
};
+ // Get difficulty badge
+ const getDifficultyBadge = (value: number | null | undefined) => {
+ if (value === null || value === undefined) return -;
+ let bgColor = '#E8F5E9', textColor = '#256029', label = 'Easy';
+ if (value >= 4) { bgColor = '#FDE8E4'; textColor = '#9E2A20'; label = 'Hard'; }
+ else if (value >= 2.5) { bgColor = '#FEF3E2'; textColor = '#8B5A00'; label = 'Medium'; }
+ return (
+
+ {label}
+
+ );
+ };
+
+ // Get workload display
+ const getWorkloadDisplay = (value: number | null | undefined) => {
+ if (value === null || value === undefined) return -;
+ const maxWorkload = 30;
+ const percentage = Math.min((value / maxWorkload) * 100, 100);
+ let textColor = '#0d6650', progressColor = 'teal';
+ if (value >= 20) { textColor = '#c92a2a'; progressColor = 'red'; }
+ else if (value >= 12) { textColor = '#7a5d00'; progressColor = 'yellow'; }
+ return (
+
+
+ {Math.round(value)}h/wk
+
+
+
+ );
+ };
+
+ // Get overall rating display
+ const getOverallDisplay = (value: number | null | undefined, numReviews: number | undefined, courseId: string) => {
+ if (value === null || value === undefined) return -;
+ let textColor = '#c92a2a', iconColor = 'var(--mantine-color-red-filled)';
+ if (value >= 4) { textColor = '#256029'; iconColor = 'var(--mantine-color-green-filled)'; }
+ else if (value >= 3) { textColor = '#0d6650'; iconColor = 'var(--mantine-color-teal-filled)'; }
+ return (
+
+
+
+
+ {value.toFixed(1)}
+
+
+
+ );
+ };
+
// Show registration status for the newest term with available data. This can
// be a future registration term, such as Fall 2026 while the calendar is still
// in Summer 2026.
@@ -719,13 +848,20 @@ export default function ScheduleContent() {
)}
- {section.aliases.length > 0 && (
+ {section.aliases.length > 0 && (
{section.aliases.join(' / ')}
)}
+ {section.isFoundational && (
+
+
+ Foundational
+
+
+ )}
{section.name}
@@ -735,6 +871,17 @@ export default function ScheduleContent() {
{section.instructor}
+
+ {getDifficultyBadge(section.avgDifficulty)}
+
+
+
+ {getWorkloadDisplay(section.avgWorkload)}
+
+
+
+ {getOverallDisplay(section.avgOverall, section.numReviews, section.courseId)}
+
@@ -773,7 +920,7 @@ export default function ScheduleContent() {
- CRN
- {isLatestAvailableSemester && Status}
- Course
- Instructor
- Enrollment
- Waitlist
+ CRN
+ {isLatestAvailableSemester && Status}
+ setSorting('courseId')}>Course
+ Instructor
+ setSorting('difficulty')}>Difficulty
+ setSorting('workload')}>Workload
+ setSorting('overall')}>Rating
+ Enrollment
+ Waitlist
{(sectionsList || []).map((section) => renderSectionRow(section, showCoreElectiveBadge))}
@@ -919,6 +1069,17 @@ export default function ScheduleContent() {
/>
+ {!selectedSpec && (
+
+ setShowFoundationalOnly(e.currentTarget.checked)}
+ color={GT_COLORS.navy}
+ />
+
+ )}
+
{/* Selected Specialization Info */}
{selectedSpec && (
diff --git a/app/schedule/page.lazy.test.ts b/app/schedule/page.lazy.test.ts
index b9ac638a..0b3b4811 100644
--- a/app/schedule/page.lazy.test.ts
+++ b/app/schedule/page.lazy.test.ts
@@ -6,7 +6,7 @@ describe('schedule page content loading boundary', () => {
it('uses the lazy schedule content wrapper', () => {
expect(source).toContain("import LazyScheduleContent from './_components/LazyScheduleContent';");
- expect(source).toContain('');
+ expect(source).toContain(';
+export default async function SchedulePage() {
+ const [coursesDataDynamic, coursesDataStatic] = await Promise.all([
+ getCourseStats(),
+ getCoursesDataStatic(),
+ ]);
+ const coursesData = mapDynamicCoursesDataToCourses(
+ coursesDataDynamic,
+ coursesDataStatic
+ );
+
+ return ;
}