From 384ef5fdb1a47867e7e0d5bd230bdcbacc0273d0 Mon Sep 17 00:00:00 2001 From: Nikita Denissov Date: Fri, 9 Oct 2026 17:16:00 +0500 Subject: [PATCH 1/3] feat(schedule): fetch and propagate course metrics to schedule view --- app/schedule/_components/LazyScheduleContent.tsx | 10 ++++++++-- app/schedule/page.tsx | 16 ++++++++++++++-- 2 files changed, 22 insertions(+), 4 deletions(-) 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/page.tsx b/app/schedule/page.tsx index 3b47310d..b98da497 100644 --- a/app/schedule/page.tsx +++ b/app/schedule/page.tsx @@ -1,11 +1,23 @@ import { Metadata } from 'next'; import LazyScheduleContent from './_components/LazyScheduleContent'; +import { getCoursesDataStatic, getCourseStats } from '@/lib/staticData'; +import { mapDynamicCoursesDataToCourses } from '@/lib/utilities'; + export const metadata: Metadata = { title: 'Course Schedule - OMSHub', description: 'View Georgia Tech OMS course schedule and enrollment data by semester', }; -export default function SchedulePage() { - return ; +export default async function SchedulePage() { + const [coursesDataDynamic, coursesDataStatic] = await Promise.all([ + getCourseStats(), + getCoursesDataStatic(), + ]); + const coursesData = mapDynamicCoursesDataToCourses( + coursesDataDynamic, + coursesDataStatic + ); + + return ; } From 452a4bd3ad6060f5d1402fa1b8313a3150663dd8 Mon Sep 17 00:00:00 2001 From: Nikita Denissov Date: Fri, 9 Oct 2026 17:16:00 +0500 Subject: [PATCH 2/3] feat(schedule): enhance table with course metrics, sorting, and foundational filter --- app/schedule/_components/ScheduleContent.tsx | 189 +++++++++++++++++-- 1 file changed, 175 insertions(+), 14 deletions(-) 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 && ( From 581430bedc421f5a422f74dcaf540e1c4c0130b1 Mon Sep 17 00:00:00 2001 From: Nikita Denissov Date: Fri, 9 Oct 2026 17:30:38 +0500 Subject: [PATCH 3/3] fix(test): allow props on LazyScheduleContent in static check --- app/schedule/page.lazy.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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('